windows-software-installer-skill 1.0.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/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # windows-software-installer-skill
2
+
3
+ > ๐Ÿ›ก๏ธ ๆ›ดๅฎ‰ๅ…จ็š„ Windows ่ฝฏไปถๅฎ‰่ฃ…ๅ™จ โ€” AI Agent Skill
4
+
5
+ [![platform](https://img.shields.io/badge/platform-Windows-blue)](https://github.com/BingWuJ/windows-software-installer)
6
+ [![type](https://img.shields.io/badge/type-Agent%20Skill-purple)](https://github.com/BingWuJ/windows-software-installer)
7
+ [![license](https://img.shields.io/badge/license-MIT-green)](https://github.com/BingWuJ/windows-software-installer)
8
+
9
+ ไธ€่กŒๅ‘ฝไปคๅฎ‰่ฃ…๏ผŒไธบ [pi Coding Agent](https://pi.dev) ๅ’Œ [Claude Code](https://claude.ai/code) ๆไพ›ๅฎ‰ๅ…จ็š„ Windows ่ฝฏไปถๅฎ‰่ฃ…่ƒฝๅŠ›ใ€‚
10
+
11
+ ## ๅฟซ้€Ÿๅฎ‰่ฃ…
12
+
13
+ ```bash
14
+ npx windows-software-installer-skill
15
+ ```
16
+
17
+ ่‡ชๅŠจๅฐ† skill ๆ–‡ไปถๅฎ‰่ฃ…ๅˆฐ `~/.agents/skills/` ๅ’Œ `~/.claude/skills/`ใ€‚
18
+
19
+ ## ไฝฟ็”จๆ–นๅผ
20
+
21
+ ๅฎ‰่ฃ…ๅฎŒๆˆๅŽ๏ผŒๅœจ pi ๆˆ– Claude Code ไธญ็›ดๆŽฅไฝฟ็”จ๏ผš
22
+
23
+ ```
24
+ /INSTALL vscode # ้€š่ฟ‡ Scoop ๅฎ‰่ฃ…
25
+ /INSTALL D:\Downloads\setup.exe # ไปŽๆœฌๅœฐๅฎ‰่ฃ…ๅŒ…ๅฎ‰่ฃ…
26
+ /INSTALL https://example.com/app.exe # ไธ‹่ฝฝๅนถ้ชŒ่ฏๅŽๅฎ‰่ฃ…
27
+ ```
28
+
29
+ ## ไธ‰็งๅฎ‰่ฃ…ๆจกๅผ
30
+
31
+ | ๆจกๅผ | ่ฏดๆ˜Ž | ๅฎ‰ๅ…จ็บงๅˆซ |
32
+ |------|------|----------|
33
+ | **Scoop** | ไปŽ Scoop ไป“ๅบ“ๅฎ‰่ฃ… | โœ… ๆœ€้ซ˜ |
34
+ | **Local file** | ไปŽๆœฌๅœฐ .exe/.msi ๅฎ‰่ฃ… | โœ… ไฟกไปป |
35
+ | **Verified download** | ไธ‹่ฝฝๅŽ SHA256/็ญพๅ้ชŒ่ฏ | โœ… ๅทฒ้ชŒ่ฏ |
36
+
37
+ ## ๅฎ‰ๅ…จๆœบๅˆถ
38
+
39
+ - ๐Ÿ”’ **SHA256 ๅ“ˆๅธŒๆ ก้ชŒ** โ€” ้ชŒ่ฏไธ‹่ฝฝๆ–‡ไปถๅฎŒๆ•ดๆ€ง
40
+ - ๐Ÿ” **Authenticode ็ญพๅ้ชŒ่ฏ** โ€” ็กฎ่ฎคๅ‘ๅธƒ่€…่บซไปฝ
41
+ - ๐ŸŒ **HTTPS ๅผบๅˆถ** โ€” ๆ‰€ๆœ‰ไธ‹่ฝฝๅฟ…้กป่ตฐๅŠ ๅฏ†่ฟžๆŽฅ
42
+ - ๐Ÿ“ **C ็›˜ไฟๆŠค** โ€” ้ป˜่ฎคๅฎ‰่ฃ…ๅˆฐ `D:\iStall\` ไฟๆŒ C ็›˜ๅนฒๅ‡€
43
+
44
+ ## ่ฆๆฑ‚
45
+
46
+ - Windows 10/11
47
+ - Node.js >= 16๏ผˆ็”จไบŽ npx ๅฎ‰่ฃ…๏ผ‰
48
+ - Python 3.8+๏ผˆๅฎ‰่ฃ…่„šๆœฌ่ฟ่กŒๆ‰€้œ€๏ผ‰
49
+
50
+ ## ๆ‰‹ๅŠจๅฎ‰่ฃ…
51
+
52
+ ```bash
53
+ git clone https://github.com/BingWuJ/windows-software-installer ~/.agents/skills/windows-software-installer
54
+ ```
55
+
56
+ ## License
57
+
58
+ MIT ยฉ [BingWuJ](https://github.com/BingWuJ)
59
+
package/bin/cli.js ADDED
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+
8
+ // โ”€โ”€โ”€ ๅธธ้‡ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
9
+ const SKILL_NAME = 'windows-software-installer';
10
+ const GREEN = '\x1b[32m';
11
+ const RED = '\x1b[31m';
12
+ const YELLOW = '\x1b[33m';
13
+ const CYAN = '\x1b[36m';
14
+ const BOLD = '\x1b[1m';
15
+ const RESET = '\x1b[0m';
16
+
17
+ // โ”€โ”€โ”€ ๅทฅๅ…ทๅ‡ฝๆ•ฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
18
+ function log(msg, color = '') {
19
+ console.log(`${color}${msg}${RESET}`);
20
+ }
21
+
22
+ function fail(msg) {
23
+ log(`\n[ERROR] ${msg}`, RED);
24
+ process.exit(1);
25
+ }
26
+
27
+ /**
28
+ * ้€’ๅฝ’ๅคๅˆถ็›ฎๅฝ•๏ผŒ่ทณ่ฟ‡ __pycache__ ๅ’Œ .pyc ๆ–‡ไปถ
29
+ */
30
+ function copyRecursiveSync(src, dest) {
31
+ const stat = fs.statSync(src);
32
+ if (stat.isDirectory()) {
33
+ fs.mkdirSync(dest, { recursive: true });
34
+ for (const entry of fs.readdirSync(src)) {
35
+ if (entry === '__pycache__' || entry.endsWith('.pyc')) continue;
36
+ copyRecursiveSync(path.join(src, entry), path.join(dest, entry));
37
+ }
38
+ } else {
39
+ fs.copyFileSync(src, dest);
40
+ }
41
+ }
42
+
43
+ // โ”€โ”€โ”€ ไธปๆต็จ‹ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
44
+
45
+ // 1. ๆฃ€ๆต‹ๆ“ไฝœ็ณป็ปŸ
46
+ if (process.platform !== 'win32') {
47
+ fail('ๆญค skill ไป…ๆ”ฏๆŒ Windows ็ณป็ปŸใ€‚');
48
+ }
49
+
50
+ // 2. ็กฎๅฎš่ทฏๅพ„
51
+ const pkgRoot = path.resolve(__dirname, '..');
52
+ const skillSrc = path.join(pkgRoot, 'skill');
53
+ const homeDir = os.homedir();
54
+ const agentsDir = path.join(homeDir, '.agents', 'skills', SKILL_NAME);
55
+ const claudeDir = path.join(homeDir, '.claude', 'skills', SKILL_NAME);
56
+
57
+ // 3. ๆฃ€ๆŸฅๅŒ…ๅ†… skill ๆ–‡ไปถๆ˜ฏๅฆๅญ˜ๅœจ
58
+ if (!fs.existsSync(path.join(skillSrc, 'SKILL.md'))) {
59
+ fail(
60
+ `ๆœชๆ‰พๅˆฐ skill ๆบๆ–‡ไปถ: ${skillSrc}\n` +
61
+ `npm ๅŒ…ๅฏ่ƒฝๅทฒๆŸๅ๏ผŒ่ฏทๅฐ่ฏ•ๆธ…้™ค npx ็ผ“ๅญ˜ๅŽ้‡่ฏ•ใ€‚`
62
+ );
63
+ }
64
+
65
+ // 4. ๅฎ‰่ฃ…ๅˆฐ ~/.agents/skills/
66
+ log(`\n${BOLD}ๆญฃๅœจๅฎ‰่ฃ… ${SKILL_NAME} skill...${RESET}`, CYAN);
67
+ log(` ็›ฎๆ ‡: ${agentsDir}\n`);
68
+
69
+ // ๆธ…็†ๅทฒๆœ‰ๅฎ‰่ฃ…
70
+ if (fs.existsSync(agentsDir)) {
71
+ fs.rmSync(agentsDir, { recursive: true, force: true });
72
+ }
73
+ fs.mkdirSync(agentsDir, { recursive: true });
74
+
75
+ // ๅคๅˆถ SKILL.md
76
+ fs.copyFileSync(
77
+ path.join(skillSrc, 'SKILL.md'),
78
+ path.join(agentsDir, 'SKILL.md')
79
+ );
80
+ log(' [OK] SKILL.md', GREEN);
81
+
82
+ // ๅคๅˆถ scripts/
83
+ const scriptsSrc = path.join(skillSrc, 'scripts');
84
+ if (fs.existsSync(scriptsSrc)) {
85
+ copyRecursiveSync(scriptsSrc, path.join(agentsDir, 'scripts'));
86
+ log(' [OK] scripts/', GREEN);
87
+ }
88
+
89
+ // ๅคๅˆถ references/
90
+ const refsSrc = path.join(skillSrc, 'references');
91
+ if (fs.existsSync(refsSrc)) {
92
+ copyRecursiveSync(refsSrc, path.join(agentsDir, 'references'));
93
+ log(' [OK] references/', GREEN);
94
+ }
95
+
96
+ // 5. ๅœจ ~/.claude/skills/ ๅˆ›ๅปบ Junction
97
+ log(`\n${BOLD}่ฎพ็ฝฎ Claude Code ้“พๆŽฅ...${RESET}`, CYAN);
98
+
99
+ const claudeSkillsDir = path.join(homeDir, '.claude', 'skills');
100
+ if (!fs.existsSync(claudeSkillsDir)) {
101
+ fs.mkdirSync(claudeSkillsDir, { recursive: true });
102
+ }
103
+
104
+ // ็งป้™คๅทฒๆœ‰็š„ junction / ็›ฎๅฝ•
105
+ // ๆณจๆ„๏ผšๅฟ…้กปๅ…ˆๅˆคๆ–ญๆ˜ฏๅฆไธบ symlink๏ผˆjunction๏ผ‰๏ผŒ็”จ unlink ่€Œ้ž rmSync
106
+ // rmSync ๅฏน junction ๅฏ่ƒฝไผš้€’ๅฝ’ๅˆ ้™ค็›ฎๆ ‡ๅ†…ๅฎน
107
+ if (fs.existsSync(claudeDir)) {
108
+ const stat = fs.lstatSync(claudeDir);
109
+ if (stat.isSymbolicLink()) {
110
+ fs.unlinkSync(claudeDir);
111
+ } else {
112
+ fs.rmSync(claudeDir, { recursive: true, force: true });
113
+ }
114
+ }
115
+
116
+ // ๅˆ›ๅปบ Junction๏ผˆWindows ็›ฎๅฝ•่”ๆŽฅ๏ผŒไธ้œ€่ฆ็ฎก็†ๅ‘˜ๆƒ้™๏ผ‰
117
+ try {
118
+ fs.symlinkSync(agentsDir, claudeDir, 'junction');
119
+ log(` [OK] Junction: ${claudeDir}`, GREEN);
120
+ log(` -> ${agentsDir}`, GREEN);
121
+ } catch (err) {
122
+ log(` [WARN] ๆ— ๆณ•ๅˆ›ๅปบ Junction: ${err.message}`, YELLOW);
123
+ log(` ่ฏทๆ‰‹ๅŠจๆ‰ง่กŒ:`, YELLOW);
124
+ log(` New-Item -ItemType Junction -Path "${claudeDir}" -Target "${agentsDir}"`, YELLOW);
125
+ }
126
+
127
+ // 6. ๅฎ‰่ฃ…ๆˆๅŠŸ
128
+ log(`\n${'='.repeat(55)}`, GREEN);
129
+ log(` ${SKILL_NAME} ๅฎ‰่ฃ…ๆˆๅŠŸ!`, GREEN);
130
+ log(`${'='.repeat(55)}`, GREEN);
131
+ log(`\n${BOLD}ๅœจ pi ๆˆ– Claude Code ไธญไฝฟ็”จ:${RESET}`);
132
+ log(` /INSTALL vscode`);
133
+ log(` /INSTALL D:\\Downloads\\setup.exe`);
134
+ log(` /INSTALL https://vendor.example/app.exe`);
135
+ log(`\n${BOLD}ๆ–‡ไปถๅฎ‰่ฃ…ไฝ็ฝฎ:${RESET}`);
136
+ log(` ${agentsDir}\n`);
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "windows-software-installer-skill",
3
+ "version": "1.0.0",
4
+ "description": "Safer Windows software installer skill for AI coding agents (pi, Claude Code). Install via Scoop, local installer, or verified download with SHA256/Authenticode checks.",
5
+ "bin": {
6
+ "windows-software-installer-skill": "bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "skill/",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "sync": "node sync-skill.js",
15
+ "prepublishOnly": "node sync-skill.js"
16
+ },
17
+ "keywords": [
18
+ "pi",
19
+ "pi-coding-agent",
20
+ "claude-code",
21
+ "ai-agent",
22
+ "skill",
23
+ "windows",
24
+ "installer",
25
+ "scoop",
26
+ "winget"
27
+ ],
28
+ "author": "bingwuj",
29
+ "license": "MIT",
30
+ "engines": {
31
+ "node": ">=16.0.0"
32
+ },
33
+ "os": [
34
+ "win32"
35
+ ]
36
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,246 @@
1
+ ---
2
+ name: windows-software-installer
3
+ description: >
4
+ Safer Windows software installer for explicit Scoop, local installer, or verified download workflows.
5
+ Use when installing Windows software with controlled modes: (1) Scoop packages, (2) trusted local
6
+ .exe/.msi installers, or (3) HTTPS downloads that pass SHA256 or Authenticode signature checks.
7
+ Also trigger on concise requests such as /INSTALL vscode, /INSTALL ๅพฎไฟก, /INSTALL D:\Downloads\setup.msi,
8
+ or /INSTALL https://vendor.example/app.exe. Avoid for generic "install anything" requests or arbitrary
9
+ unverified download-and-run flows. Note: winget is used only for discovery (to find the official download
10
+ URL or confirm a package exists), not for installation โ€” winget's --location flag is frequently ignored
11
+ by installers, which would defeat the goal of keeping software off the C drive.
12
+ ---
13
+
14
+ # Windows Software Installer
15
+
16
+ Install Windows software through explicit modes with safer defaults. This skill keeps the existing default paths to avoid filling the C drive:
17
+
18
+ - Default install directory: `D:\iStall\{software_name}`
19
+ - Default download cache: `D:\WUDownloadCache`
20
+
21
+ ## Quick Entry
22
+
23
+ Use `/INSTALL <payload>` as the short entrypoint for this skill. Treat `/INSTALL` and `/install` the same way.
24
+
25
+ Interpret `<payload>` in this order:
26
+
27
+ 1. Absolute Windows path: use `local-file`
28
+ 2. HTTPS URL: use `download-verified`
29
+ 3. Anything else: treat as a software name โ†’ run `winget search <name>` and `scoop search <name>` only for discovery, then choose one explicit mode before installing
30
+
31
+ Examples:
32
+
33
+ ```text
34
+ /INSTALL vscode
35
+ /INSTALL ๅพฎไฟก
36
+ /INSTALL D:\Downloads\Typeless-0.4.6-x64-Setup.exe
37
+ /INSTALL https://vendor.example/app.exe
38
+ ```
39
+
40
+ Rules:
41
+
42
+ - `/INSTALL` is only a shorter entrypoint. It does not bypass any security checks.
43
+ - Do not guess download links from vague software names.
44
+ - Reject non-HTTPS URLs.
45
+ - Reject local files that are not `.exe` or `.msi`.
46
+ - If the request is more complex than a single payload, fall back to the explicit CLI arguments below.
47
+ - For software-name payloads, discovery may inform the choice, but do not auto-switch modes during installation.
48
+
49
+ ## Modes
50
+
51
+ ### `scoop`
52
+ Use when the software exists in Scoop and you want the lowest-risk path. Note: `--installer-type` is not needed for scoop mode, Scoop manages the installation internally.
53
+
54
+ ```bash
55
+ python scripts/install_windows_software.py \
56
+ --mode scoop \
57
+ --software-name vscode
58
+ ```
59
+
60
+ Optional bucket:
61
+
62
+ ```bash
63
+ python scripts/install_windows_software.py \
64
+ --mode scoop \
65
+ --software-name neovim-nightly \
66
+ --scoop-bucket nightly
67
+ ```
68
+
69
+ ### `local-file`
70
+ Use when the installer has already been downloaded manually and you trust the file source.
71
+
72
+ ```bash
73
+ python scripts/install_windows_software.py \
74
+ --mode local-file \
75
+ --software-name typeless \
76
+ --local-file "D:\Downloads\Typeless-0.4.6-x64-Setup.exe" \
77
+ --installer-type nsis
78
+ ```
79
+
80
+ ### `download-verified`
81
+ Use when you need the script to download the installer itself. Only HTTPS is allowed. The installer must either:
82
+
83
+ - pass `--sha256`, or
84
+ - have a valid Authenticode signature
85
+
86
+ If the signature is valid but the publisher is not in the allowlist, the script asks for confirmation unless `--allow-untrusted-publisher` is provided.
87
+
88
+ ```bash
89
+ python scripts/install_windows_software.py \
90
+ --mode download-verified \
91
+ --software-name wechat \
92
+ --download-url "https://example.com/WeChatSetup.exe" \
93
+ --installer-type nsis \
94
+ --publisher Tencent
95
+ ```
96
+
97
+ With SHA256:
98
+
99
+ ```bash
100
+ python scripts/install_windows_software.py \
101
+ --mode download-verified \
102
+ --software-name sumatrapdf \
103
+ --download-url "https://www.sumatrapdfreader.org/dl/rel/3.5.2/SumatraPDF-3.5.2-64-install.exe" \
104
+ --installer-type nsis \
105
+ --sha256 "<sha256>"
106
+ ```
107
+
108
+ Interactive mode (launches the installer UI with the install directory pre-filled; success here means the installer was launched, and you handle the remaining steps to avoid bundled software):
109
+
110
+ ```bash
111
+ python scripts/install_windows_software.py \
112
+ --mode download-verified \
113
+ --software-name wechat \
114
+ --download-url "https://example.com/WeChatSetup.exe" \
115
+ --installer-type nsis \
116
+ --publisher Tencent \
117
+ --interactive
118
+ ```
119
+
120
+ With CDN Referer protection (download URL requires a Referer header):
121
+
122
+ ```bash
123
+ python scripts/install_windows_software.py \
124
+ --mode download-verified \
125
+ --software-name shandianshuo \
126
+ --download-url "https://download.shandianshuo.cn/windows/shandianshuo_0.6.7_x64-setup.exe" \
127
+ --installer-type nsis \
128
+ --referer "https://shandianshuo.cn/" \
129
+ --allow-untrusted-publisher
130
+ ```
131
+
132
+ ## Security Rules
133
+
134
+ - Use one explicit mode. Discovery is allowed, but installation must use a single chosen mode with no automatic fallback.
135
+ - Only HTTPS download URLs are allowed.
136
+ - Do not pass arbitrary installer argument strings.
137
+ - `download-verified` refuses unsigned installers.
138
+ - Signed installers from unknown publishers require confirmation unless `--allow-untrusted-publisher` is set.
139
+ - `local-file` mode copies the installer into the install directory for execution, but does not delete the original file.
140
+
141
+ ## Parameters
142
+
143
+ | Parameter | Required | Modes | Description |
144
+ |---|---|---|---|
145
+ | `--mode` | Yes | all | `scoop`, `local-file`, or `download-verified` |
146
+ | `--software-name` | Yes | all | Safe identifier used for Scoop and default install dir |
147
+ | `--installer-type` | Yes | local-file, download-verified | `nsis`, `inno`, or `msi` |
148
+ | `--scoop-bucket` | No | scoop | Optional Scoop bucket |
149
+ | `--local-file` | Yes | local-file | Absolute path to a trusted `.exe` or `.msi` |
150
+ | `--download-url` | Yes | download-verified | HTTPS URL to the installer |
151
+ | `--sha256` | No | download-verified | Strongest verification when vendor provides a hash |
152
+ | `--publisher` | No | download-verified | Expected publisher hint for signed installers |
153
+ | `--allow-untrusted-publisher` | No | download-verified | Skip the confirmation step for valid but unlisted publishers |
154
+ | `--referer` | No | download-verified | Referer header URL for CDN hotlink protection (e.g. the software's official site) |
155
+ | `--interactive` | No | local-file, download-verified | Launch installer in interactive mode so you can manually uncheck bundled software |
156
+ | `--install-dir` | No | all | Override install destination; default is `D:\iStall\{software_name}` |
157
+
158
+ ## Full Auto Workflow (recommended for winget-listed software)
159
+
160
+ When the software has a winget entry with `Installer Type: exe/msi`, use this pipeline to discover the URL automatically and download via Neat Download Manager:
161
+
162
+ ```powershell
163
+ # Step 1: find the Package ID
164
+ winget search <name>
165
+
166
+ # Step 2: extract official URL and SHA256
167
+ winget show <PackageId>
168
+ # Look for:
169
+ # Installer Url: https://...
170
+ # Installer SHA256: <hash>
171
+ ```
172
+
173
+ ```bash
174
+ # Step 3: download-verified with the values from winget show
175
+ python scripts/install_windows_software.py \
176
+ --mode download-verified \
177
+ --software-name <name> \
178
+ --download-url "<Installer Url>" \
179
+ --installer-type <inno|nsis|msi> \
180
+ --sha256 "<Installer SHA256>"
181
+ ```
182
+
183
+ The script auto-detects Neat Download Manager (via registry) and uses it as the download engine. If NDM is not installed it falls back to aria2c, and if aria2c also fails, it falls back to PowerShell `Invoke-WebRequest` (which supports `--referer` for CDN hotlink protection). After downloading, it verifies the SHA256 and installs to `D:\iStall\<name>`. Use this only after you have explicitly chosen `download-verified` as the install mode.
184
+
185
+ **Note:** `winget show` sometimes lists `Installer Type: msstore` โ€” those are Microsoft Store apps. They cannot be redirected to D: and this workflow does not apply.
186
+
187
+ ## When To Prefer Each Mode
188
+
189
+ - **Discovery first**: Run `winget search <name>` and `scoop search <name>` to find what's available. winget has broader coverage for mainstream consumer software; scoop is better for CLI/dev tools.
190
+ - **Do NOT install via winget** even if it lists the package โ€” winget's `--location` flag is frequently ignored by the underlying installer, which defeats the goal of keeping software off the C drive.
191
+ - **Use `scoop`** when the package is found in Scoop. Scoop respects the install location because it manages the directory itself.
192
+ - **Use `local-file`** for installers you already downloaded yourself.
193
+ - **Use `download-verified`** when scoop doesn't have it and you need to download from a vendor URL. Always required โ€” downloading from an "official" URL alone is not sufficient, as CDN compromise or MITM can tamper with the file in transit.
194
+ - **Add `--interactive`** when the installer is known to bundle unwanted software (common with Chinese consumer apps like WeChat, Baidu, 360). The script will pre-fill the install directory and launch the installer UI; you then manually uncheck bundled offers and complete the installation.
195
+
196
+ **Exception**: if the user explicitly accepts C drive installation, winget is fine and simpler.
197
+
198
+ ## Finding the Download URL from an Official Website
199
+
200
+ When winget and scoop both come up empty, you need to find the download URL from the vendor's website. The full discovery flow is:
201
+
202
+ **Step 1: Web search to find the official website.**
203
+
204
+ Use a web search tool to look up the software name. The goal is to find the vendor's official site URL (not a third-party download mirror).
205
+
206
+ **Step 2: Fetch the raw HTML and extract download links.**
207
+
208
+ **Do not use webReader / web_fetch_exa / tavily_extract** for this โ€” these tools render pages into readable markdown and strip HTML tag attributes, so `<a href="https://download.example.com/setup.exe">` download links are lost entirely.
209
+
210
+ Instead, fetch the raw HTML and search for installer URLs:
211
+
212
+ ```powershell
213
+ $env:HTTPS_PROXY = 'http://127.0.0.1:10809'
214
+ $env:HTTP_PROXY = 'http://127.0.0.1:10809'
215
+ $env:NODE_USE_ENV_PROXY = '1'
216
+ node -e "fetch('https://official-site.example/').then(r=>r.text()).then(t=>{const matches=t.match(/https?:\/\/[^\s\"'<>]+\.exe/gi); console.log(matches||'no exe links found')}).catch(e=>console.error(e.message))"
217
+ ```
218
+
219
+ This returns the direct download URLs embedded in the page. If Node.js is not available, use the PowerShell equivalent:
220
+
221
+ ```powershell
222
+ $response = Invoke-WebRequest -Uri 'https://official-site.example/' -UseBasicParsing
223
+ $matches = [regex]::Matches($response.Content, 'https?://[^\s""''<>]+\.exe', 'IgnoreCase')
224
+ if ($matches.Count -gt 0) { $matches.Value } else { Write-Host 'no exe links found' }
225
+ ```
226
+
227
+ If the site is a single-page app that loads content via JavaScript, the HTML source may not contain the links โ€” in that case try the site's GitHub releases page, or search the web for `<software name> download url exe` to find cached or documented download links.
228
+
229
+ ## Troubleshooting
230
+
231
+ **Q: The vendor does not publish SHA256.**
232
+ A: Use `download-verified` with a valid digital signature and `--publisher`. If the signer is valid but not allowlisted, review the displayed signer and confirm manually.
233
+
234
+ **Q: I do not want software to install to C:.**
235
+ A: The default install path remains `D:\iStall\{software_name}`. You can also set `--install-dir` explicitly.
236
+
237
+ **Q: Can I still use arbitrary installer switches?**
238
+ A: No. This skill intentionally removes free-form installer arguments to reduce command injection and mismatched installer behavior.
239
+
240
+ **Q: Download fails with 403 Forbidden from CDN.**
241
+ A: Many Chinese software CDNs (e.g. `download.*.cn` subdomains) enforce Referer-based hotlink protection โ€” they reject downloads that don't carry the official site as the `Referer` header. NDM and aria2c do not send Referer headers, so the script falls back to PowerShell `Invoke-WebRequest`. Use `--referer "https://official-site.example/"` to pass the official site URL. If you see an error mentioning `Referer ACL` or `denied by Referer ACL`, this is the cause.
242
+
243
+ ## Reference Materials
244
+
245
+ - See [references/scoop-buckets.md](references/scoop-buckets.md) for bucket guidance.
246
+ - See [references/installer-patterns.md](references/installer-patterns.md) for the installer templates supported by this skill.
@@ -0,0 +1,147 @@
1
+ {
2
+ "skill_name": "windows-software-installer",
3
+ "evals": [
4
+ {
5
+ "id": 0,
6
+ "prompt": "/INSTALL vscode",
7
+ "expected_output": "่ฏ†ๅˆซ /INSTALL ไธบ็ฎ€ๆดๅ…ฅๅฃ๏ผŒๅนถๆ˜ ๅฐ„ๅˆฐ scoop ๆจกๅผ",
8
+ "assertions": [
9
+ "Recognizes /INSTALL as the skill trigger",
10
+ "Treats vscode as a software name",
11
+ "Uses --mode scoop"
12
+ ]
13
+ },
14
+ {
15
+ "id": 1,
16
+ "prompt": "ๅธฎๆˆ‘็”จ Scoop ๅฎ‰่ฃ… VS Code",
17
+ "expected_output": "่ฏ†ๅˆซไธบๆ˜พๅผ scoop ๆจกๅผ๏ผŒ็ป™ๅ‡บๆ›ดๅฎ‰ๅ…จ็š„ๆ–ฐๅ‘ฝไปคๆ ผๅผ",
18
+ "assertions": [
19
+ "Uses --mode scoop",
20
+ "Uses --software-name vscode"
21
+ ]
22
+ },
23
+ {
24
+ "id": 4,
25
+ "prompt": "/INSTALL D:\\Downloads\\app.msi",
26
+ "expected_output": "่ฏ†ๅˆซ /INSTALL + ็ปๅฏน่ทฏๅพ„ไธบ local-file ๆจกๅผ๏ผŒๅนถๆŽจๆ–ญ msi ๅฎ‰่ฃ…ๅ™จ็ฑปๅž‹",
27
+ "assertions": [
28
+ "Recognizes /INSTALL as the skill trigger",
29
+ "Uses --mode local-file",
30
+ "Uses --local-file parameter",
31
+ "Uses --installer-type msi"
32
+ ]
33
+ },
34
+ {
35
+ "id": 5,
36
+ "prompt": "/INSTALL https://vendor.example/app.exe",
37
+ "expected_output": "่ฏ†ๅˆซ /INSTALL + HTTPS URL ไธบ download-verified ๆจกๅผ",
38
+ "assertions": [
39
+ "Recognizes /INSTALL as the skill trigger",
40
+ "Uses --mode download-verified",
41
+ "Uses --download-url",
42
+ "Mentions SHA256 or Authenticode verification"
43
+ ]
44
+ },
45
+ {
46
+ "id": 2,
47
+ "prompt": "ๅธฎๆˆ‘ไปŽๅฎ˜็ฝ‘ไธ‹่ฝฝๅฎ‰่ฃ…ๅพฎไฟก๏ผŒๅฆ‚ๆžœๆฒกๆœ‰ SHA256 ๅฐฑ็”จ็ญพๅๆ ก้ชŒ",
48
+ "expected_output": "่ฏ†ๅˆซไธบ download-verified ๆจกๅผ๏ผŒๅนถ่ฆๆฑ‚ HTTPS ไธŽ็ญพๅๆˆ–ๅ“ˆๅธŒ้ชŒ่ฏ",
49
+ "assertions": [
50
+ "Uses --mode download-verified",
51
+ "Uses --download-url",
52
+ "Mentions SHA256 or Authenticode verification",
53
+ "Mentions publisher confirmation or allowlist"
54
+ ]
55
+ },
56
+ {
57
+ "id": 3,
58
+ "prompt": "ไปŽๆœฌๅœฐๆ–‡ไปถ D:\\Downloads\\app.msi ๅฎ‰่ฃ…่ฝฏไปถ",
59
+ "expected_output": "่ฏ†ๅˆซไธบ local-file ๆจกๅผ๏ผŒๅนถไฝฟ็”จ็ปๅฏน่ทฏๅพ„ๅ’Œๅฎ‰่ฃ…ๅ™จ็ฑปๅž‹",
60
+ "assertions": [
61
+ "Uses --mode local-file",
62
+ "Uses --local-file parameter",
63
+ "Uses --installer-type msi"
64
+ ]
65
+ },
66
+ {
67
+ "id": 6,
68
+ "prompt": "/INSTALL sumatrapdf --sha256 abc123",
69
+ "expected_output": "download-verified ๆจกๅผ + SHA256 ๅ“ˆๅธŒๆ ก้ชŒ",
70
+ "assertions": [
71
+ "Uses --mode download-verified",
72
+ "Uses --download-url",
73
+ "Uses --sha256",
74
+ "Mentions SHA256 verification"
75
+ ]
76
+ },
77
+ {
78
+ "id": 7,
79
+ "prompt": "ไปŽๅฎ˜็ฝ‘ไธ‹่ฝฝๅฎ‰่ฃ…้—ช็”ต่ฏด๏ผŒๅฎ˜็ฝ‘ๆ˜ฏ shandianshuo.cn๏ผŒๅฏ่ƒฝๆœ‰ CDN ้˜ฒ็›—้“พ",
80
+ "expected_output": "download-verified ๆจกๅผ + referer ๅ‚ๆ•ฐ",
81
+ "assertions": [
82
+ "Uses --mode download-verified",
83
+ "Uses --referer",
84
+ "Mentions CDN or Referer"
85
+ ]
86
+ },
87
+ {
88
+ "id": 8,
89
+ "prompt": "ๅฎ‰่ฃ…ๅพฎไฟก๏ผŒ่ฆ็”จไบคไบ’ๅผๅฎ‰่ฃ…๏ผŒๆˆ‘ๆ€•ๅฎƒๆ†็ป‘่ฝฏไปถ",
90
+ "expected_output": "download-verified ๆจกๅผ + interactive ๅ‚ๆ•ฐ",
91
+ "assertions": [
92
+ "Uses --mode download-verified",
93
+ "Uses --interactive",
94
+ "Mentions bundled software or manual uncheck"
95
+ ]
96
+ },
97
+ {
98
+ "id": 9,
99
+ "prompt": "/INSTALL D:\\Downloads\\app-setup.exe",
100
+ "expected_output": "local-file ๆจกๅผ + exe (้ž msi)",
101
+ "assertions": [
102
+ "Uses --mode local-file",
103
+ "Uses --local-file",
104
+ "Mentions installer type for exe"
105
+ ]
106
+ },
107
+ {
108
+ "id": 10,
109
+ "prompt": "/INSTALL http://example.com/app.exe",
110
+ "expected_output": "ๆ‹’็ป้ž HTTPS URL",
111
+ "assertions": [
112
+ "Rejects non-HTTPS URL",
113
+ "Requires HTTPS download URL"
114
+ ]
115
+ },
116
+ {
117
+ "id": 11,
118
+ "prompt": "/INSTALL D:\\Downloads\\readme.txt",
119
+ "expected_output": "ๆ‹’็ป้ž exe/msi ๆ–‡ไปถ",
120
+ "assertions": [
121
+ "Rejects non-exe/msi file",
122
+ "Mentions only exe or msi allowed"
123
+ ]
124
+ },
125
+ {
126
+ "id": 12,
127
+ "prompt": "ๅธฎๆˆ‘ๅœจ winget ้‡Œๆœ็ดข Firefox๏ผŒ็„ถๅŽ็”จๅฎƒ็š„ๅฎ˜ๆ–นไธ‹่ฝฝ้“พๆŽฅๅฎ‰่ฃ…",
128
+ "expected_output": "winget discover + download-verified ๆจกๅผ๏ผˆFull Auto Workflow๏ผ‰",
129
+ "assertions": [
130
+ "Runs winget search for discovery",
131
+ "Uses winget show to get URL and SHA256",
132
+ "Uses --mode download-verified",
133
+ "Uses --sha256 from winget output"
134
+ ]
135
+ },
136
+ {
137
+ "id": 13,
138
+ "prompt": "/INSTALL D:\\Downloads\\MyApp-setup.exe ่ฟ™ไธชๅฎ‰่ฃ…ๅŒ…ๆ˜ฏ Inno Setup ๆ ผๅผ็š„",
139
+ "expected_output": "local-file ๆจกๅผ + inno ๅฎ‰่ฃ…ๅ™จ็ฑปๅž‹",
140
+ "assertions": [
141
+ "Uses --mode local-file",
142
+ "Uses --installer-type inno",
143
+ "Mentions Inno Setup"
144
+ ]
145
+ }
146
+ ]
147
+ }
@@ -0,0 +1,56 @@
1
+ # Supported Installer Patterns
2
+
3
+ This skill no longer accepts free-form installer arguments. It uses built-in templates for a small set of installer types that can reliably honor the requested install directory.
4
+
5
+ ## Supported Types
6
+
7
+ ### NSIS
8
+ - Base flags: `/S`
9
+ - Install directory: `/D=<path>` appended by the script
10
+
11
+ ### Inno Setup
12
+ - Base flags: `/VERYSILENT /NORESTART`
13
+ - Install directory: `/DIR="<path>"` appended by the script
14
+
15
+ ### MSI
16
+ - Executed through `msiexec`
17
+ - Base flags: `/quiet /norestart`
18
+ - Install directory: `TARGETDIR=<path> INSTALLDIR=<path>` (both passed for maximum compatibility)
19
+
20
+
21
+ ## Verification Rules
22
+
23
+ ### `download-verified`
24
+ A download is allowed to run only if one of these conditions is true:
25
+
26
+ 1. The provided `--sha256` matches the downloaded file.
27
+ 2. The file has a valid Authenticode signature and either:
28
+ - the signer matches the publisher allowlist, or
29
+ - the user confirms a valid but unlisted publisher.
30
+
31
+ Unsigned downloads are rejected.
32
+
33
+ ### `local-file`
34
+ Local installers must be `.exe` or `.msi` files on an absolute path.
35
+
36
+ ## Interactive Mode (`--interactive`)
37
+
38
+ When `--interactive` is set, the silent flags are omitted but the install directory is still passed. This launches the installer UI with the directory pre-filled. In interactive mode, success means the installer was launched; the user then manually:
39
+
40
+ - Uncheck bundled software offers (common in Chinese consumer apps)
41
+ - Review license agreements
42
+ - Choose custom components
43
+
44
+ **Flags used in interactive mode:**
45
+
46
+ | Type | Interactive flags |
47
+ |------|-------------------|
48
+ | NSIS | `/D=<path>` (no `/S`) |
49
+ | Inno | `/DIR="<path>"` (no `/VERYSILENT /NORESTART`) |
50
+ | MSI | `TARGETDIR=... INSTALLDIR=...` (no `/quiet /norestart`) |
51
+
52
+ Interactive mode is only available for `local-file` and `download-verified` modes, not `scoop`.
53
+
54
+ ## Why this skill removed custom arguments
55
+
56
+ The previous version accepted arbitrary installer switches and executed shell strings directly. That made command injection and unsafe installer execution too easy. The current version keeps behavior predictable and auditable by using fixed templates.
@@ -0,0 +1,141 @@
1
+ # Scoop Buckets Reference
2
+
3
+ Common Scoop buckets and their typical software packages.
4
+
5
+ ## Official Buckets
6
+
7
+ ### `main` (default)
8
+ The default bucket containing stable, well-maintained software.
9
+
10
+ Common packages:
11
+ - `python` - Python programming language
12
+ - `nodejs` - Node.js JavaScript runtime
13
+ - `git` - Git version control
14
+ - `vim` - Vim text editor
15
+ - `neovim` - Neovim text editor
16
+ - `ffmpeg` - FFmpeg multimedia framework
17
+ - `curl` - curl data transfer tool
18
+ - `wget` - wget download utility
19
+ - `grep` - grep text search
20
+ - `tar` - tar archive utility
21
+
22
+ ### `extras`
23
+ Software that doesn't meet the criteria for the main bucket but is still useful.
24
+
25
+ Common packages:
26
+ - `vscode` - Visual Studio Code
27
+ - `chrome` - Google Chrome browser
28
+ - `firefox` - Mozilla Firefox browser
29
+ - `notepadplusplus` - Notepad++ text editor
30
+ - `sublime-text` - Sublime Text editor
31
+ - `obsidian` - Obsidian note-taking app
32
+ - `typora` - Typora markdown editor
33
+ - `telegram` - Telegram messenger
34
+ - `discord` - Discord chat app
35
+ - `spotify` - Spotify music streaming
36
+ - `steam` - Steam gaming platform
37
+ - `vlc` - VLC media player
38
+
39
+ ### `versions`
40
+ Multiple versions of software (nightly, beta, portable, etc.)
41
+
42
+ Common packages:
43
+ - `python-nightly`
44
+ - `nodejs-nightly`
45
+ - `rust-nightly`
46
+ - `gcc-nightly`
47
+
48
+ ### `nightly`
49
+ Nightly builds of software.
50
+
51
+ Common packages:
52
+ - `neovim-nightly`
53
+ - `ruby-nightly`
54
+
55
+ ### `nerd-fonts`
56
+ Nerd Fonts - patched fonts for developers.
57
+
58
+ Common packages:
59
+ - `FiraCode-NF`
60
+ - `JetBrainsMono-NF`
61
+ - `Hack-NF`
62
+ - `SourceCodePro-NF`
63
+
64
+ ## Third-Party Buckets
65
+
66
+ ### `nonportable`
67
+ Contains non-portable versions of software (requires installation, not portable).
68
+
69
+ Usage:
70
+ ```bash
71
+ scoop bucket add nonportable
72
+ scoop install nonportable/<app-name>
73
+ ```
74
+
75
+ ### `games`
76
+ Game-related software and tools.
77
+
78
+ ### `java`
79
+ Java Development Kits and Runtime Environments.
80
+
81
+ Common packages:
82
+ - `temurin-jdk` - Eclipse Temurin JDK (formerly AdoptOpenJDK)
83
+ - `openjdk` - OpenJDK
84
+ - `oraclejdk` - Oracle JDK
85
+
86
+ ## Managing Buckets
87
+
88
+ ### Add a bucket:
89
+ ```bash
90
+ scoop bucket add <bucket-name>
91
+ ```
92
+
93
+ ### List known buckets:
94
+ ```bash
95
+ scoop bucket known
96
+ ```
97
+
98
+ ### List installed buckets:
99
+ ```bash
100
+ scoop bucket list
101
+ ```
102
+
103
+ ### Remove a bucket:
104
+ ```bash
105
+ scoop bucket rm <bucket-name>
106
+ ```
107
+
108
+ ## Searching for Software
109
+
110
+ ### Search all buckets:
111
+ ```bash
112
+ scoop search <software-name>
113
+ ```
114
+
115
+ ### Search specific bucket:
116
+ ```bash
117
+ scoop search <software-name> # Shows results from all buckets with bucket names
118
+ ```
119
+
120
+ ## Bucket Selection Tips
121
+
122
+ 1. **Try main bucket first** - Most stable and well-maintained packages
123
+ 2. **Use extras for GUI apps** - Popular desktop applications
124
+ 3. **Use versions for alternatives** - When you need specific versions or variants
125
+ 4. **Use nightly for bleeding edge** - Latest development builds (may be unstable)
126
+ 5. **Add third-party buckets as needed** - For specialized software
127
+
128
+ ## Example: Finding Software
129
+
130
+ To find where VS Code is located:
131
+ ```bash
132
+ scoop search vscode
133
+ # Output shows: 'vscode' bucket 'extras'
134
+ ```
135
+
136
+ Then install with:
137
+ ```bash
138
+ scoop install vscode --bucket extras
139
+ # or simply (scoop finds it automatically):
140
+ scoop install vscode
141
+ ```
@@ -0,0 +1,542 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Windows Software Installer
4
+
5
+ Install software on Windows using one of three explicit modes:
6
+ - scoop
7
+ - local-file
8
+ - download-verified
9
+ """
10
+
11
+ import argparse
12
+ import hashlib
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+ from urllib.parse import urlparse
19
+
20
+
21
+ DEFAULT_INSTALL_ROOT = Path("D:/iStall")
22
+ DEFAULT_DOWNLOAD_CACHE = Path("D:/WUDownloadCache")
23
+ SAFE_NAME_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._+")
24
+ SAFE_BUCKET_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")
25
+ SAFE_EXTENSIONS = {".exe", ".msi"}
26
+ DEFAULT_PUBLISHERS = {
27
+ "Tencent Technology (Shenzhen) Company Limited",
28
+ "Tencent",
29
+ "Microsoft Corporation",
30
+ "Google LLC",
31
+ "Mozilla Corporation",
32
+ "Notepad++ Team",
33
+ "VideoLAN",
34
+ "GitHub, Inc.",
35
+ }
36
+
37
+ INSTALLER_TEMPLATES = {
38
+ "nsis": ["/S"],
39
+ "inno": ["/VERYSILENT", "/NORESTART"],
40
+ "msi": ["/quiet", "/norestart"],
41
+ }
42
+
43
+
44
+ def safe_print_command(cmd):
45
+ print("Running:", " ".join(str(part) for part in cmd))
46
+
47
+
48
+ def run_command(cmd, check=True, capture_output=True):
49
+ safe_print_command(cmd)
50
+ return subprocess.run(
51
+ cmd,
52
+ check=check,
53
+ capture_output=capture_output,
54
+ text=True,
55
+ )
56
+
57
+
58
+ def fail(message):
59
+ print(message, file=sys.stderr)
60
+ return 1
61
+
62
+
63
+ def ps_single_quote(value):
64
+ return str(value).replace("'", "''")
65
+
66
+
67
+ def validate_safe_token(value, label, allowed_chars):
68
+ if not value or any(char not in allowed_chars for char in value):
69
+ raise ValueError(f"Invalid {label}: {value!r}")
70
+ return value
71
+
72
+
73
+ def ensure_windows_absolute_path(path_value, label):
74
+ path = Path(path_value)
75
+ if not path.is_absolute():
76
+ raise ValueError(f"{label} must be an absolute path: {path_value}")
77
+ return path
78
+
79
+
80
+ def get_default_install_dir(software_name):
81
+ return DEFAULT_INSTALL_ROOT / software_name
82
+
83
+
84
+ def validate_installer_file(path):
85
+ if not path.exists():
86
+ raise ValueError(f"Installer file not found: {path}")
87
+ if not path.is_file():
88
+ raise ValueError(f"Installer path is not a file: {path}")
89
+ if path.suffix.lower() not in SAFE_EXTENSIONS:
90
+ raise ValueError(f"Unsupported installer type: {path.suffix}")
91
+
92
+
93
+ def validate_download_url(url):
94
+ parsed = urlparse(url)
95
+ if parsed.scheme.lower() != "https":
96
+ raise ValueError("download-url must use HTTPS")
97
+ if not parsed.netloc:
98
+ raise ValueError("download-url must include a hostname")
99
+ return parsed
100
+
101
+
102
+ def verify_sha256(path, expected_sha256):
103
+ digest = hashlib.sha256()
104
+ with path.open("rb") as handle:
105
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
106
+ digest.update(chunk)
107
+ actual = digest.hexdigest().lower()
108
+ return actual == expected_sha256.lower(), actual
109
+
110
+
111
+ def get_authenticode_signature(path):
112
+ escaped = str(path).replace("'", "''")
113
+ command = [
114
+ "pwsh",
115
+ "-NoProfile",
116
+ "-Command",
117
+ (
118
+ f"Get-AuthenticodeSignature -FilePath '{escaped}' | "
119
+ "Select-Object @{Name='Status';Expression={$_.Status.ToString()}},"
120
+ "StatusMessage,"
121
+ "@{Name='Signer';Expression={"
122
+ "if ($_.SignerCertificate) { $_.SignerCertificate.Subject } else { '' }"
123
+ "}} | ConvertTo-Json -Compress"
124
+ ),
125
+ ]
126
+ result = run_command(command, check=False)
127
+ if result.returncode != 0 or not result.stdout.strip():
128
+ return None
129
+
130
+ try:
131
+ import json
132
+
133
+ payload = json.loads(result.stdout)
134
+ except Exception:
135
+ return None
136
+
137
+ return {
138
+ "status": payload.get("Status", ""),
139
+ "status_message": payload.get("StatusMessage", ""),
140
+ "signer": payload.get("Signer", ""),
141
+ }
142
+
143
+
144
+ def signature_is_valid(signature):
145
+ return signature and signature.get("status") == "Valid" and signature.get("signer")
146
+
147
+
148
+ def signer_matches_allowlist(signature, publisher, allowlisted_publishers):
149
+ signer = signature.get("signer", "")
150
+ if publisher:
151
+ return publisher.lower() in signer.lower()
152
+ return any(candidate.lower() in signer.lower() for candidate in allowlisted_publishers)
153
+
154
+
155
+ def confirm_untrusted_publisher(signature):
156
+ signer = signature.get("signer", "")
157
+ print("Publisher is not in the allowlist.")
158
+ print(f"Detected publisher: {signer}")
159
+ answer = input("Continue anyway? [y/N]: ").strip().lower()
160
+ return answer in {"y", "yes"}
161
+
162
+
163
+ def check_scoop_installed():
164
+ result = run_command(["scoop", "--version"], check=False)
165
+ return result.returncode == 0
166
+
167
+
168
+ def install_with_scoop(software_name, bucket=None):
169
+ if not check_scoop_installed():
170
+ print("Scoop is not installed.")
171
+ return False
172
+
173
+ command = ["scoop", "install", software_name]
174
+ if bucket:
175
+ command.extend(["--bucket", bucket])
176
+
177
+ result = run_command(command, check=False)
178
+ if result.returncode != 0:
179
+ print(f"Scoop installation of {software_name} failed")
180
+ return False
181
+
182
+ verify_result = run_command(["scoop", "list"], check=False)
183
+ if verify_result.returncode != 0:
184
+ print("Could not verify Scoop installation")
185
+ return False
186
+
187
+ installed = any(
188
+ line.split()[0].lower() == software_name.lower()
189
+ for line in verify_result.stdout.splitlines()
190
+ if line.strip() and not line.startswith("Installed apps:")
191
+ )
192
+ if installed:
193
+ print(f"Successfully installed {software_name} via Scoop")
194
+ return True
195
+
196
+ print(f"Scoop did not report {software_name} as installed")
197
+ return False
198
+
199
+
200
+ def find_ndm():
201
+ """Return (ndm_exe, ndm_db) if Neat Download Manager is installed, else (None, None)."""
202
+ try:
203
+ import winreg
204
+ for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):
205
+ for subpath in (
206
+ r"Software\Microsoft\Windows\CurrentVersion\Uninstall",
207
+ r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
208
+ ):
209
+ try:
210
+ key = winreg.OpenKey(hive, subpath)
211
+ except OSError:
212
+ continue
213
+ i = 0
214
+ while True:
215
+ try:
216
+ sub = winreg.OpenKey(key, winreg.EnumKey(key, i))
217
+ try:
218
+ name = winreg.QueryValueEx(sub, "DisplayName")[0]
219
+ if "Neat Download Manager" in name:
220
+ loc = winreg.QueryValueEx(sub, "InstallLocation")[0]
221
+ exe = Path(loc) / "NeatDM.exe"
222
+ db = Path(os.environ["APPDATA"]) / "NeatDM" / "NeatDB.db"
223
+ if exe.exists() and db.exists():
224
+ return exe, db
225
+ except OSError:
226
+ pass
227
+ finally:
228
+ sub.Close()
229
+ i += 1
230
+ except OSError:
231
+ break
232
+ key.Close()
233
+ except Exception:
234
+ pass
235
+ return None, None
236
+
237
+
238
+ def download_with_ndm(download_url, ndm_exe, ndm_db, timeout=600):
239
+ """Dispatch a URL to Neat Download Manager and wait for completion.
240
+ Returns the Path of the downloaded file."""
241
+ import sqlite3, time
242
+
243
+ conn = sqlite3.connect(str(ndm_db))
244
+ max_id = conn.execute("SELECT COALESCE(MAX(id), 0) FROM downloads").fetchone()[0]
245
+ conn.close()
246
+
247
+ print(f"Sending to NDM: {download_url}")
248
+ subprocess.Popen([str(ndm_exe), download_url])
249
+
250
+ deadline = time.time() + timeout
251
+ last_status = ""
252
+ while time.time() < deadline:
253
+ time.sleep(2)
254
+ try:
255
+ conn = sqlite3.connect(str(ndm_db))
256
+ row = conn.execute(
257
+ "SELECT filename, status, folderpath FROM downloads "
258
+ "WHERE id > ? AND url = ? ORDER BY id DESC LIMIT 1",
259
+ (max_id, download_url),
260
+ ).fetchone()
261
+ conn.close()
262
+ except Exception:
263
+ continue
264
+
265
+ if not row:
266
+ continue
267
+ filename, status, folderpath = row
268
+ if status != last_status:
269
+ print(f"NDM: {status}")
270
+ last_status = status
271
+
272
+ if "Complete" in status:
273
+ return Path(folderpath) / filename
274
+ if "Error" in status:
275
+ raise RuntimeError(f"NDM download failed: {status}")
276
+
277
+ raise RuntimeError("NDM download timed out after 10 minutes")
278
+
279
+
280
+ def download_with_powershell(download_url, destination, referer=None):
281
+ """Fallback download using PowerShell Invoke-WebRequest.
282
+ Supports Referer header for CDN hotlink protection."""
283
+ escaped_url = ps_single_quote(download_url)
284
+ escaped_destination = ps_single_quote(destination)
285
+ headers_clause = ""
286
+ if referer:
287
+ escaped_referer = ps_single_quote(referer)
288
+ headers_clause = f"-Headers @{{Referer='{escaped_referer}'}}"
289
+
290
+ cmd = [
291
+ "pwsh", "-NoProfile", "-Command",
292
+ (
293
+ "$ProgressPreference = 'SilentlyContinue'; "
294
+ f"Invoke-WebRequest -Uri '{escaped_url}' -OutFile '{escaped_destination}' "
295
+ f"-UseBasicParsing {headers_clause}"
296
+ ),
297
+ ]
298
+ result = run_command(cmd, check=False)
299
+ return result.returncode == 0 and destination.exists()
300
+
301
+
302
+ def download_installer(download_url, destination, referer=None):
303
+ """Download to destination. Tries NDM โ†’ aria2c โ†’ PowerShell (with Referer support)."""
304
+ destination.parent.mkdir(parents=True, exist_ok=True)
305
+
306
+ ndm_exe, ndm_db = find_ndm()
307
+ if ndm_exe:
308
+ try:
309
+ downloaded = download_with_ndm(download_url, ndm_exe, ndm_db)
310
+ shutil.copy2(downloaded, destination)
311
+ return True
312
+ except Exception as exc:
313
+ print(f"NDM failed ({exc}), falling back to aria2c")
314
+
315
+ result = run_command(
316
+ ["aria2c", "--allow-overwrite=true", "-d", str(destination.parent), "-o", destination.name, download_url],
317
+ check=False,
318
+ )
319
+ if result.returncode == 0:
320
+ return True
321
+
322
+ print("aria2c failed, falling back to PowerShell Invoke-WebRequest")
323
+ return download_with_powershell(download_url, destination, referer)
324
+
325
+
326
+ def build_install_command(installer_path, installer_type, install_dir, interactive=False):
327
+ installer_type = installer_type.lower()
328
+ if installer_type == "installshield":
329
+ raise ValueError("installshield installers are not supported because a generic silent install cannot reliably honor --install-dir")
330
+ if installer_type not in INSTALLER_TEMPLATES:
331
+ raise ValueError(f"Unsupported installer type: {installer_type}")
332
+
333
+ if installer_type == "msi":
334
+ cmd = ["msiexec", "/i", str(installer_path)]
335
+ if not interactive:
336
+ cmd.extend(INSTALLER_TEMPLATES["msi"])
337
+ cmd.append(f"TARGETDIR={install_dir}")
338
+ cmd.append(f"INSTALLDIR={install_dir}")
339
+ return cmd
340
+
341
+ command = [str(installer_path)]
342
+ if not interactive:
343
+ command.extend(INSTALLER_TEMPLATES[installer_type])
344
+
345
+ if installer_type == "nsis":
346
+ command.append(f"/D={install_dir}")
347
+ elif installer_type == "inno":
348
+ command.append(f'/DIR="{install_dir}"')
349
+ return command
350
+
351
+
352
+ def verify_manual_install(install_dir, ignored_paths=None):
353
+ if not install_dir.exists():
354
+ return False
355
+
356
+ ignored = {Path(path).resolve() for path in (ignored_paths or [])}
357
+ for pattern in ("*.exe", "*.lnk", "*.dll"):
358
+ for candidate in install_dir.rglob(pattern):
359
+ if candidate.resolve() not in ignored:
360
+ return True
361
+ return False
362
+
363
+
364
+ def prepare_local_installer(local_file, software_name, install_dir):
365
+ source = ensure_windows_absolute_path(local_file, "local-file")
366
+ validate_installer_file(source)
367
+ install_dir.mkdir(parents=True, exist_ok=True)
368
+
369
+ copied_path = install_dir / f"{software_name}-installer{source.suffix.lower()}"
370
+ shutil.copy2(source, copied_path)
371
+ return copied_path
372
+
373
+
374
+ def prepare_downloaded_installer(download_url, software_name, install_dir, referer=None):
375
+ parsed = validate_download_url(download_url)
376
+ install_dir.mkdir(parents=True, exist_ok=True)
377
+ DEFAULT_DOWNLOAD_CACHE.mkdir(parents=True, exist_ok=True)
378
+
379
+ extension = Path(parsed.path).suffix.lower()
380
+ if extension not in SAFE_EXTENSIONS:
381
+ raise ValueError("download-url must point to an .exe or .msi installer")
382
+
383
+ cache_path = DEFAULT_DOWNLOAD_CACHE / f"{software_name}-installer{extension}"
384
+ if not download_installer(download_url, cache_path, referer):
385
+ raise RuntimeError(f"Failed to download installer from {download_url}")
386
+
387
+ final_path = install_dir / cache_path.name
388
+ shutil.copy2(cache_path, final_path)
389
+ return cache_path, final_path
390
+
391
+
392
+ def validate_downloaded_installer(installer_path, sha256_value, publisher, allow_untrusted_publisher):
393
+ if sha256_value:
394
+ is_valid, actual_sha256 = verify_sha256(installer_path, sha256_value)
395
+ if not is_valid:
396
+ raise RuntimeError(
397
+ "SHA256 mismatch for downloaded installer. "
398
+ f"Expected {sha256_value.lower()}, got {actual_sha256}"
399
+ )
400
+ print("SHA256 verification passed.")
401
+ return
402
+
403
+ signature = get_authenticode_signature(installer_path)
404
+ if not signature_is_valid(signature):
405
+ raise RuntimeError("Downloaded installer does not have a valid Authenticode signature")
406
+
407
+ if signer_matches_allowlist(signature, publisher, DEFAULT_PUBLISHERS):
408
+ print(f"Trusted signed publisher detected: {signature['signer']}")
409
+ return
410
+
411
+ if allow_untrusted_publisher or confirm_untrusted_publisher(signature):
412
+ print(f"Proceeding with signed but untrusted publisher: {signature['signer']}")
413
+ return
414
+
415
+ raise RuntimeError("Publisher is signed but not allowlisted")
416
+
417
+
418
+ def install_manually(installer_path, installer_type, install_dir):
419
+ command = build_install_command(installer_path, installer_type, install_dir)
420
+ result = run_command(command, check=False)
421
+ if result.returncode != 0:
422
+ print("Manual installation failed")
423
+ return False
424
+
425
+ if verify_manual_install(install_dir, ignored_paths=[installer_path]):
426
+ print(f"Successfully installed software to {install_dir}")
427
+ return True
428
+
429
+ print("Installer exited successfully but installation could not be verified")
430
+ return False
431
+
432
+
433
+ def install_interactively(installer_path, installer_type, install_dir):
434
+ command = build_install_command(installer_path, installer_type, install_dir, interactive=True)
435
+ safe_print_command(command)
436
+ subprocess.Popen(command)
437
+ print()
438
+ print(f"Installer launched successfully. Install directory pre-set to: {install_dir}")
439
+ print("Installation is not complete yet. Finish the remaining steps manually and uncheck any bundled software.")
440
+
441
+
442
+ def parse_args():
443
+ parser = argparse.ArgumentParser(
444
+ description="Install software on Windows using explicit and safer installation modes"
445
+ )
446
+ parser.add_argument("--mode", required=True, choices=["scoop", "local-file", "download-verified"])
447
+ parser.add_argument("--software-name", "--software_name", required=True, dest="software_name")
448
+ parser.add_argument("--scoop-bucket", "--scoop_bucket", dest="scoop_bucket")
449
+ parser.add_argument("--local-file", "--local_file", dest="local_file")
450
+ parser.add_argument("--download-url", "--download_url", dest="download_url")
451
+ parser.add_argument("--sha256")
452
+ parser.add_argument(
453
+ "--installer-type",
454
+ required=False,
455
+ default=None,
456
+ choices=["nsis", "inno", "msi"],
457
+ )
458
+ parser.add_argument("--install-dir", dest="install_dir")
459
+ parser.add_argument("--publisher")
460
+ parser.add_argument("--referer", dest="referer",
461
+ help="Referer header for CDN hotlink protection (e.g. the official site URL)")
462
+ parser.add_argument("--allow-untrusted-publisher", action="store_true")
463
+ parser.add_argument("--interactive", action="store_true",
464
+ help="Launch installer interactively instead of silent, so you can manually uncheck bundled software")
465
+ return parser.parse_args()
466
+
467
+
468
+ def main():
469
+ args = parse_args()
470
+
471
+ try:
472
+ software_name = validate_safe_token(args.software_name, "software-name", SAFE_NAME_CHARS)
473
+ if args.scoop_bucket:
474
+ validate_safe_token(args.scoop_bucket, "scoop-bucket", SAFE_BUCKET_CHARS)
475
+
476
+ install_dir = (
477
+ ensure_windows_absolute_path(args.install_dir, "install-dir")
478
+ if args.install_dir
479
+ else get_default_install_dir(software_name)
480
+ )
481
+ install_dir.mkdir(parents=True, exist_ok=True)
482
+ except ValueError as error:
483
+ return fail(str(error))
484
+
485
+ if args.mode == "scoop":
486
+ if args.local_file or args.download_url or args.sha256 or args.publisher or args.allow_untrusted_publisher or args.interactive or args.installer_type:
487
+ return fail("scoop mode does not accept local-file, download-url, sha256, publisher, allow-untrusted-publisher, interactive, or installer-type")
488
+ return 0 if install_with_scoop(software_name, args.scoop_bucket) else 1
489
+
490
+ temp_paths = []
491
+ try:
492
+ if args.mode == "local-file":
493
+ if args.download_url or args.sha256 or args.publisher or args.allow_untrusted_publisher or args.referer:
494
+ return fail("local-file mode does not accept download-url, sha256, publisher, allow-untrusted-publisher, or referer")
495
+ if not args.local_file:
496
+ return fail("local-file mode requires --local-file")
497
+
498
+ if not args.installer_type:
499
+ return fail("--installer-type is required for local-file mode")
500
+ installer_path = prepare_local_installer(args.local_file, software_name, install_dir)
501
+ temp_paths.append(installer_path)
502
+ if args.interactive:
503
+ install_interactively(installer_path, args.installer_type, install_dir)
504
+ return 0
505
+
506
+ success = install_manually(installer_path, args.installer_type, install_dir)
507
+ return 0 if success else 1
508
+
509
+ if args.local_file:
510
+ return fail("download-verified mode does not accept --local-file")
511
+ if not args.download_url:
512
+ return fail("download-verified mode requires --download-url")
513
+
514
+ if not args.installer_type:
515
+ return fail("--installer-type is required for download-verified mode")
516
+ cache_path, installer_path = prepare_downloaded_installer(args.download_url, software_name, install_dir, referer=args.referer)
517
+ temp_paths.append(installer_path)
518
+ validate_downloaded_installer(
519
+ installer_path,
520
+ args.sha256,
521
+ args.publisher,
522
+ args.allow_untrusted_publisher,
523
+ )
524
+ if args.interactive:
525
+ install_interactively(installer_path, args.installer_type, install_dir)
526
+ return 0
527
+
528
+ success = install_manually(installer_path, args.installer_type, install_dir)
529
+ return 0 if success else 1
530
+ except (RuntimeError, ValueError) as error:
531
+ return fail(str(error))
532
+ finally:
533
+ for path in temp_paths:
534
+ try:
535
+ if path.exists():
536
+ path.unlink()
537
+ except OSError:
538
+ pass
539
+
540
+
541
+ if __name__ == "__main__":
542
+ sys.exit(main())