uniner-cli 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/LICENSE ADDED
@@ -0,0 +1,4 @@
1
+ Copyright (c) 2026 Uniner Tech. All rights reserved.
2
+
3
+ This software is proprietary. Use is subject to the terms of the
4
+ license agreement at https://www.uniner.com/.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # uniner-cli
2
+
3
+ `uniner-cli` 是 [Uniner](https://www.uniner.com/) 的 RPA 自动化命令行工具,提供:
4
+
5
+ - **Web 自动化**(Playwright + CDP)—— 浏览器抓取、表单填写、元素交互
6
+ - **Excel 操作** —— 读写、批量处理、样式
7
+ - **OCR 识别** —— ddddocr(验证码)+ PaddleOCR(通用)
8
+ - **桌面 / SAP 自动化**(仅 Windows)
9
+ - **通用工具** —— 加密、HTTP、邮件、文件、PDF、压缩
10
+
11
+ 安装时会自动注册一个 [Claude Code](https://claude.com/claude-code) 技能(`~/.claude/skills/uniner-cli/SKILL.md`),让 Claude 一启动就能识别并调用本 CLI。
12
+
13
+ ---
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ npm install -g uniner-cli
19
+ ```
20
+
21
+ 安装过程:
22
+
23
+ 1. npm 装本壳包(约 50KB)
24
+ 2. `postinstall` 钩子从 `static.uniner.com` 拉取约 250MB 便携包
25
+ 3. 解压到 `%USERPROFILE%\.uniner-cli\<version>\`
26
+ 4. 写入 `%USERPROFILE%\.claude\skills\uniner-cli\SKILL.md`
27
+
28
+ 完成后任意终端:
29
+
30
+ ```bash
31
+ uniner-cli --help
32
+ ```
33
+
34
+ ## 系统要求
35
+
36
+ | 项 | 要求 |
37
+ |---|---|
38
+ | 操作系统 | 64 位 Windows 10 / 11 |
39
+ | Node.js | >= 14 |
40
+ | 磁盘空间 | ≥ 1.5 GB |
41
+ | 网络 | 能访问 `static.uniner.com` |
42
+
43
+ > Linux / macOS 支持在路上。
44
+
45
+ ## 使用示例
46
+
47
+ ```bash
48
+ # 看一级命令
49
+ uniner-cli --help
50
+
51
+ # 浏览器自动化
52
+ uniner-cli web browser open --url "https://example.com"
53
+ uniner-cli web element click --xpath "//button[@id='submit']"
54
+ uniner-cli web element get-text --xpath "//h1"
55
+
56
+ # Excel
57
+ uniner-cli excel data read --file data.xlsx --cell A1
58
+ uniner-cli excel data write --file out.xlsx --cell B2 --value "hello"
59
+
60
+ # OCR
61
+ uniner-cli tool ocr ddddocr --img captcha.png
62
+ ```
63
+
64
+ 完整命令树(一级 + 二级 + 三级参数)见安装后 `%USERPROFILE%\.uniner-cli\<version>\skills\cli\SKILL.md`。
65
+
66
+ ## 升级
67
+
68
+ ```bash
69
+ npm update -g uniner-cli
70
+ ```
71
+
72
+ 或者重新安装:
73
+
74
+ ```bash
75
+ npm install -g uniner-cli@latest
76
+ ```
77
+
78
+ > 不支持降级。如需回滚,请手动卸载后再装旧版。
79
+
80
+ ## 卸载
81
+
82
+ ```bash
83
+ # 1. 先清理运行时数据(删 ~/.uniner-cli/ 和 Claude Code 技能)
84
+ uniner-cli-uninstall
85
+
86
+ # 2. 再卸载 npm 包本身
87
+ npm uninstall -g uniner-cli
88
+ ```
89
+
90
+ > **为什么是两步?** npm 7+ 出于安全考虑不再在卸载时运行 lifecycle 脚本,所以我们提供 `uniner-cli-uninstall` 命令让你显式触发清理。
91
+
92
+ 清理会移除:
93
+
94
+ - `%USERPROFILE%\.uniner-cli\<version>\` 安装目录
95
+ - `%USERPROFILE%\.claude\skills\uniner-cli\SKILL.md` 技能
96
+
97
+ ## 环境变量
98
+
99
+ 无需手动配置。npm 全局 bin 目录已自动加入 PATH。
100
+
101
+ ## 故障排查
102
+
103
+ ### `uniner-cli: command not found`
104
+
105
+ 确认 npm 全局 bin 在 PATH 上:
106
+
107
+ ```powershell
108
+ npm config get prefix
109
+ # 应输出类似 C:\Users\xxx\AppData\Roaming\npm
110
+ $env:Path -split ';' | Where-Object { $_ -like '*npm*' }
111
+ ```
112
+
113
+ 如果没有,手动加 `%APPDATA%\npm` 到 PATH。
114
+
115
+ ### 安装时下载失败 / 超时
116
+
117
+ 公司代理或防火墙可能屏蔽 `static.uniner.com`。试试:
118
+
119
+ ```bash
120
+ npm config get proxy
121
+ npm config get https-proxy
122
+ # 设置代理:
123
+ npm config set https-proxy http://your-proxy:port
124
+ ```
125
+
126
+ 或者直接走原生安装方式(不走 npm):
127
+
128
+ ```powershell
129
+ iwr https://static.uniner.com/soft/editor/uninercli/install.ps1 -UseBasicParsing | iex
130
+ ```
131
+
132
+ ### postinstall 卡在解压
133
+
134
+ 杀软(360 / 火绒)会扫描 1279 个新文件,慢则 5–10 分钟。让它跑完即可。
135
+
136
+ ### 想重装
137
+
138
+ ```bash
139
+ npm uninstall -g uniner-cli
140
+ npm install -g uniner-cli
141
+ ```
142
+
143
+ ## 备选安装方式(不走 npm)
144
+
145
+ 如果你不想要 Node.js 依赖,可以用 PowerShell 一行安装(基于 Inno Setup 安装器):
146
+
147
+ ```powershell
148
+ iwr https://static.uniner.com/soft/editor/uninercli/install.ps1 -UseBasicParsing | iex
149
+ ```
150
+
151
+ 两种方式互不干扰,但**不要**同时启用——会有两份 SKILL.md / 两份二进制。
152
+
153
+ ## 链接
154
+
155
+ - 官网:[https://www.uniner.com/](https://www.uniner.com/)
156
+ - 技术支持:support@uniner.com
157
+ - Claude Code:[https://claude.com/claude-code](https://claude.com/claude-code)
158
+
159
+ ## License
160
+
161
+ Proprietary. (C) 2026 Uniner Tech.
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ // uniner-cli-uninstall - removes the runtime tree and Claude Code skill.
3
+ //
4
+ // npm 7+ no longer runs preuninstall/postuninstall lifecycle scripts on
5
+ // uninstall (security tightening). So we ship this command for users to
6
+ // run BEFORE `npm uninstall -g uniner-cli` to clean their machine.
7
+ //
8
+ // Recommended workflow:
9
+ // uniner-cli-uninstall # this script: clean ~/.uniner-cli/ + skill
10
+ // npm uninstall -g uniner-cli # npm: remove the npm package itself
11
+ //
12
+ // Idempotent: if files are already gone, just no-ops.
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ const {
20
+ pkg,
21
+ rootDir,
22
+ versionDir,
23
+ skillDir,
24
+ } = require('../lib/paths');
25
+
26
+ function log(msg) { console.log(`[uniner-cli] ${msg}`); }
27
+ function warn(msg) { console.warn(`[uniner-cli] [WARN] ${msg}`); }
28
+
29
+ function rmRf(p) {
30
+ try {
31
+ if (fs.existsSync(p)) {
32
+ fs.rmSync(p, { recursive: true, force: true });
33
+ log(`Removed ${p}`);
34
+ return true;
35
+ }
36
+ return false;
37
+ } catch (e) {
38
+ warn(`Could not remove ${p}: ${e.message}`);
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function main() {
44
+ console.log('');
45
+ console.log('==========================================');
46
+ console.log(' uniner-cli uninstall cleanup');
47
+ console.log('==========================================');
48
+
49
+ const myVersion = pkg.version;
50
+ const myVersionDir = versionDir(myVersion);
51
+ let didSomething = false;
52
+
53
+ // 1. Remove our version's runtime tree.
54
+ if (rmRf(myVersionDir)) didSomething = true;
55
+
56
+ // 2. If ~/.uniner-cli/ has no other versions, drop the whole directory.
57
+ try {
58
+ const root = rootDir();
59
+ if (fs.existsSync(root)) {
60
+ const remaining = fs.readdirSync(root);
61
+ if (remaining.length === 0) {
62
+ fs.rmdirSync(root);
63
+ log(`Removed empty ${root}`);
64
+ didSomething = true;
65
+ } else {
66
+ log(`Other uniner-cli versions still installed: ${remaining.join(', ')}`);
67
+ log(`Keeping ${root}`);
68
+ }
69
+ }
70
+ } catch (e) {
71
+ warn(`Could not inspect ${rootDir()}: ${e.message}`);
72
+ }
73
+
74
+ // 3. Remove the Claude Code skill.
75
+ if (rmRf(skillDir())) didSomething = true;
76
+
77
+ if (!didSomething) {
78
+ log('Nothing to clean — already removed.');
79
+ }
80
+
81
+ console.log('');
82
+ console.log('Cleanup done. To complete uninstall, also run:');
83
+ console.log(' npm uninstall -g uniner-cli');
84
+ console.log('');
85
+ }
86
+
87
+ main();
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // Tiny launcher: forward every CLI invocation to the real PyInstaller exe.
3
+ //
4
+ // `npm install -g uniner-cli` registers this file as the global `uniner-cli`
5
+ // command (because of the `bin` entry in package.json). We just spawn the real
6
+ // binary with the same argv/stdio and exit with its status code.
7
+
8
+ 'use strict';
9
+
10
+ const { spawnSync } = require('child_process');
11
+ const fs = require('fs');
12
+ const { realBinaryPath, isInstalled, pkg } = require('../lib/paths');
13
+
14
+ const exe = realBinaryPath();
15
+
16
+ if (!isInstalled()) {
17
+ console.error('');
18
+ console.error(`[uniner-cli] Real binary not found at: ${exe}`);
19
+ console.error('[uniner-cli] The npm postinstall step must not have completed.');
20
+ console.error('[uniner-cli] Try one of:');
21
+ console.error('[uniner-cli] npm rebuild -g uniner-cli');
22
+ console.error('[uniner-cli] npm uninstall -g uniner-cli && npm install -g uniner-cli');
23
+ console.error('');
24
+ process.exit(127);
25
+ }
26
+
27
+ // stdio:'inherit' forwards stdin/stdout/stderr unchanged so interactive prompts
28
+ // and ANSI color survive. shell:false avoids any path-quoting surprises.
29
+ const result = spawnSync(exe, process.argv.slice(2), {
30
+ stdio: 'inherit',
31
+ shell: false,
32
+ windowsHide: false,
33
+ });
34
+
35
+ if (result.error) {
36
+ console.error(`[uniner-cli] Failed to launch ${exe}: ${result.error.message}`);
37
+ process.exit(1);
38
+ }
39
+
40
+ // `status` is null when the child was killed by a signal; in that case let
41
+ // the user see we did not exit cleanly.
42
+ process.exit(result.status === null ? 1 : result.status);
@@ -0,0 +1,187 @@
1
+ ---
2
+ name: uniner-cli
3
+ description: RPA 自动化 CLI 工具,提供浏览器自动化、Excel 操作、OCR 识别、桌面/SAP 自动化、加密/HTTP/邮件等通用工具能力。Use when user mentions RPA、自动化、抓取网页、抓数据、填表、读 Excel、写 Excel、识别验证码、OCR、SAP、桌面操作、uniner-cli、爬虫、表单自动化.
4
+ ---
5
+
6
+ # uniner-cli Skill
7
+
8
+ > 由 UninerCLI Installer v{{VERSION}} 自动安装到 `%USERPROFILE%\.claude\skills\uniner-cli\`
9
+ > 安装位置(绝对路径):`{{INSTALL_PATH}}`
10
+
11
+ ## 何时使用本 Skill
12
+
13
+ 用户提到以下任一关键词或任务,**优先调用 uniner-cli**:
14
+
15
+ - **网页类**:打开网页、抓取数据、点击/填表/登录、获取标题/表格、OCR 验证码
16
+ - **Excel 类**:创建/读写 Excel、单元格操作、批量数据导出
17
+ - **桌面类**:SAP 操作、窗口控制、键鼠模拟、图像识别(仅 Windows)
18
+ - **工具类**:MD5/AES/RSA 加密、HTTP 请求、收发邮件、文件/PDF/ZIP 操作
19
+
20
+ ## 调用前提(已自动配置)
21
+
22
+ - ✅ `uniner-cli` 命令已加入 PATH(安装时自动配置,新终端立即生效)
23
+ - ✅ 环境变量 `UNINER_CLI_HOME = {{INSTALL_PATH}}`
24
+ - ✅ Web 模块自动通过 CDP 连接本机 Chrome / Edge —— **不要手动启动 Chrome**
25
+ - ⚠️ desktop 模块仅 Windows,依赖外部 Agent 服务(魔奈 RPA 编辑器自带)
26
+
27
+ ## 命令结构(一级 + 二级)
28
+
29
+ ```
30
+ uniner-cli <模块> <子模块> <命令> [选项]
31
+
32
+ excel Excel 文件操作
33
+ ├── file 创建 / 打开 / 关闭 / 保存 / 复制 / 删除 / info / list
34
+ ├── sheet 工作表 创建 / 删除 / 重命名 / 复制 / info / list
35
+ ├── data 读 / 写 / 区域读 / 区域写 / 查找 / 清除
36
+ └── style 字体 / 边框 / 填充 / 对齐 / 数字格式
37
+
38
+ web 浏览器自动化(Playwright + CDP)
39
+ ├── browser open / close / reconnect
40
+ ├── navigation goto / back / forward / refresh / wait
41
+ ├── element click / input / get-text / get-value / select / check / hover / screenshot
42
+ ├── iframe enter / exit / list
43
+ ├── page list / switch / close
44
+ └── action screenshot / execute-js / get-cookie / set-cookie
45
+
46
+ tool 通用工具
47
+ ├── list 创建 / append / remove / find / sort / unique
48
+ ├── dict 创建 / get / set / remove / keys / values
49
+ ├── time now / format / parse / calc / diff
50
+ ├── string format / replace / split / join / trim / upper / lower
51
+ ├── data json-parse / json-stringify / csv-parse / csv-write
52
+ ├── file read / write / copy / move / delete / list / info / exists
53
+ ├── system execute / kill-app / env / clipboard [Windows]
54
+ ├── ui alert / confirm / prompt [Windows]
55
+ ├── crypto md5 / sha256 / aes-encrypt / aes-decrypt / rsa-* / base64-*
56
+ ├── math add / sub / mul / div / round / abs / min / max
57
+ ├── pdf read / merge / split / extract-text
58
+ ├── ocr ddddocr(验证码) / paddleocr(通用) / image-to-text
59
+ ├── zip create / extract / list
60
+ ├── ftp connect / upload / download / list / delete
61
+ ├── email send / receive / list
62
+ └── http get / post / put / delete
63
+
64
+ desktop 桌面自动化 [仅 Windows]
65
+ ├── window find / activate / close / info / list
66
+ ├── element find / click / get-text / set-value
67
+ ├── keyboard type / press / hotkey
68
+ ├── mouse click / move / scroll / drag
69
+ ├── image find / click / wait
70
+ └── sap login / transaction / field / table
71
+ ```
72
+
73
+ ## 全局选项(写在子命令前)
74
+
75
+ ```bash
76
+ uniner-cli [全局选项] <模块> <子命令> <命令> [命令选项]
77
+
78
+ --timeout, -t <毫秒> 超时(默认 3000ms)
79
+ --exc <0|1> 异常处理:0=抛错退出,1=静默返回错误 JSON
80
+ --verbose, -v 详细日志
81
+ --quiet, -q 静默
82
+ --json/--plain 输出格式(默认 --json)
83
+ --config, -c <PATH> 配置文件路径
84
+ ```
85
+
86
+ ## 输出格式(始终是 JSON)
87
+
88
+ ```json
89
+ // 成功
90
+ {"success": true, "command": "web browser open", "data": {"session_id": "abc123"}, "timestamp": "..."}
91
+
92
+ // 失败
93
+ {"success": false, "error": "元素未找到: xpath=...", "error_type": "ElementNotFoundError"}
94
+ ```
95
+
96
+ 调用方应 `json.loads(stdout)` 解析,**不要 grep/regex**。
97
+
98
+ ## 场景 → 命令映射(高频)
99
+
100
+ | 用户意图 | 命令模板 |
101
+ |---|---|
102
+ | 打开网页 | `web browser open --url <URL>` |
103
+ | 等待元素出现 | `web navigation wait --type element --xpath <X>` |
104
+ | 点击 | `web element click --xpath <X>` |
105
+ | 输入文本 | `web element input --xpath <X> --value <V>` |
106
+ | 获取文本 | `web element get-text --xpath <X>` |
107
+ | 获取表格 / 复杂提取 | `web action execute-js --code "..."` |
108
+ | 元素截图 | `web element screenshot --xpath <X> --output <PATH>` |
109
+ | 进 iframe | `web iframe enter --xpath <X>`(或 XPath JSON 写法) |
110
+ | 创建 Excel | `excel file create --file <PATH>` |
111
+ | 读单元格 | `excel data read --file <PATH> --cell A1` |
112
+ | 写单元格 | `excel data write --file <PATH> --cell A1 --value <V>` |
113
+ | 区域写入 | `excel data write-range --file <PATH> --cell A1 --values <2D-JSON>` |
114
+ | OCR 验证码 | `tool ocr ddddocr --img <PATH>` |
115
+ | OCR 通用 | `tool ocr paddleocr --img <PATH>` |
116
+ | MD5 / SHA256 | `tool crypto md5 --text <S>` |
117
+ | HTTP GET | `tool http get --url <URL>` |
118
+ | 发送邮件 | `tool email send --to <addr> --subject <S> --body <B>` |
119
+ | 结束进程 | `tool system kill-app --name chrome.exe` |
120
+ | SAP 登录 | `desktop sap login --user <U> --password <P>` |
121
+
122
+ ## 命令选择优先级
123
+
124
+ 1. **网页操作只用 `web`**(即使是桌面里嵌的 web)
125
+ 2. **`web action execute-js` 是万能 fallback**:Vue/React 组件、动态元素、复杂表格提取,普通 element 命令做不到时直接 JS
126
+ 3. **iframe 嵌套**用 XPath JSON 格式:`{"ElementName":"...","XPaths":[["iframe_xpath","element_xpath"],["backup_xpath"]]}`
127
+ 4. **session 自动管理**:第一次 `web browser open` 后,后续 web 命令默认用最新 session,可省 `--session`
128
+
129
+ ## 关键注意事项(6 条 TOC,详情看链接)
130
+
131
+ 1. **Chrome CDP 自动管理** — 别手动 kill Chrome;客户机必须装 Chrome 或 Edge
132
+ 2. **session 持久化** — `$TEMP\.uniner-cli\sessions.json`;CLI 退出后用 `web browser reconnect` 恢复
133
+ 3. **Vue/Element UI 组件** — 日期选择器、级联下拉等,用 `execute-js` 直接操作组件模型,不要硬 input
134
+ 4. **desktop 返回 -999** = Agent 服务未启动;此时改用 `web action execute-js` 或 `tool system execute`
135
+ 5. **Linux 平台** — Playwright 默认 HTTPS-only,访问 HTTP 站点需改 wrapper 加 `--disable-features=HttpsOnlyMode`,或用 Firefox `--browser-type 1`
136
+ 6. **Windows GBK 编码** — CLI 内部已强制 UTF-8 输出;调用方读 stdout 也要 UTF-8
137
+
138
+ ## 详细文档(按需 Read)
139
+
140
+ | 主题 | 路径 |
141
+ |---|---|
142
+ | 命令树全集 + 三级参数 | [{{INSTALL_PATH}}\skills\cli\SKILL.md]({{INSTALL_PATH}}\skills\cli\SKILL.md) |
143
+ | SDK ↔ CLI 命令映射 | [{{INSTALL_PATH}}\skills\cli\sdk-cli-mapping.md]({{INSTALL_PATH}}\skills\cli\sdk-cli-mapping.md) |
144
+ | Windows 配置指南 | [{{INSTALL_PATH}}\skills\windows-setup-guide.md]({{INSTALL_PATH}}\skills\windows-setup-guide.md) |
145
+ | Linux 配置指南 | [{{INSTALL_PATH}}\skills\linux-setup-guide.md]({{INSTALL_PATH}}\skills\linux-setup-guide.md) |
146
+ | 实战案例(TMS 抓单) | [{{INSTALL_PATH}}\skills\tms-workflow-test-report.md]({{INSTALL_PATH}}\skills\tms-workflow-test-report.md) |
147
+ | Helper 脚本(Chrome 启动 / Vue 日期 / 表格提取) | [{{INSTALL_PATH}}\skills\cli\helpers\]({{INSTALL_PATH}}\skills\cli\helpers\) |
148
+ | 用户手册 | [{{INSTALL_PATH}}\docs\]({{INSTALL_PATH}}\docs\) |
149
+ | 工作目录(保存截图/Excel/下载) | [{{INSTALL_PATH}}\workdir\]({{INSTALL_PATH}}\workdir\) |
150
+
151
+ ## 典型完整流程(参考)
152
+
153
+ **网页 → OCR → 提取 → Excel**:
154
+
155
+ ```bash
156
+ # 1. 打开浏览器
157
+ uniner-cli web browser open --url "https://example.com/login"
158
+
159
+ # 2. 输账号密码 + 截验证码 + OCR
160
+ uniner-cli web element input --xpath "//input[@id='user']" --value "u01"
161
+ uniner-cli web element input --xpath "//input[@id='pwd']" --value "p01"
162
+ uniner-cli web element screenshot --xpath "//img[@id='captcha']" --output "{{INSTALL_PATH}}\workdir\cap.png"
163
+ uniner-cli tool ocr ddddocr --img "{{INSTALL_PATH}}\workdir\cap.png"
164
+ # 从返回 JSON 的 data.result 取识别文本
165
+
166
+ # 3. 提交登录
167
+ uniner-cli web element input --xpath "//input[@id='captcha']" --value "<识别结果>"
168
+ uniner-cli web element click --xpath "//button[@type='submit']"
169
+
170
+ # 4. 等表格 + 提取
171
+ uniner-cli web navigation wait --type element --xpath "//table"
172
+ uniner-cli web action execute-js --code "return Array.from(document.querySelectorAll('table tr')).map(r => Array.from(r.cells).map(c => c.textContent.trim()))"
173
+
174
+ # 5. 写 Excel
175
+ uniner-cli excel file create --file "{{INSTALL_PATH}}\workdir\out.xlsx"
176
+ uniner-cli excel data write-range --file "{{INSTALL_PATH}}\workdir\out.xlsx" --cell A1 --values "<上一步的 JSON 二维数组>"
177
+
178
+ # 6. 关浏览器
179
+ uniner-cli web browser close
180
+ ```
181
+
182
+ ## 不确定时
183
+
184
+ - `uniner-cli --help` 看一级
185
+ - `uniner-cli web --help` 看二级
186
+ - `uniner-cli web element click --help` 看具体参数
187
+ - 仍不确定 → Read [{{INSTALL_PATH}}\skills\cli\SKILL.md]({{INSTALL_PATH}}\skills\cli\SKILL.md)
package/lib/paths.js ADDED
@@ -0,0 +1,78 @@
1
+ // Path utilities shared by bin/, scripts/postinstall.js, scripts/preuninstall.js.
2
+ // Single source of truth so we do not drift between launcher and installer.
3
+
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+
8
+ const pkg = require('../package.json');
9
+
10
+ // ----------------------------------------------------------------------
11
+ // Layout we install into:
12
+ //
13
+ // Windows: %USERPROFILE%\.uniner-cli\<version>\
14
+ // bin\uniner-cli\uniner-cli.exe ← real PyInstaller exe
15
+ // skills\
16
+ // docs\
17
+ // workdir\
18
+ // Linux: ~/.uniner-cli/<version>/ (not yet shipped)
19
+ //
20
+ // We keep <version> in the path so two npm-installed versions can coexist
21
+ // without colliding (`npm install uniner-cli@1.0.0` then `npm install uniner-cli@1.0.1`
22
+ // won't clobber each other), and so postinstall is idempotent: if the
23
+ // version-specific dir already has a working binary, we skip the download.
24
+ // ----------------------------------------------------------------------
25
+
26
+ function homeDir() {
27
+ return os.homedir();
28
+ }
29
+
30
+ function rootDir() {
31
+ return path.join(homeDir(), '.uniner-cli');
32
+ }
33
+
34
+ function versionDir(version) {
35
+ return path.join(rootDir(), version || pkg.version);
36
+ }
37
+
38
+ function realBinaryPath(version) {
39
+ const ver = version || pkg.version;
40
+ if (process.platform === 'win32') {
41
+ return path.join(versionDir(ver), 'bin', 'uniner-cli', 'uniner-cli.exe');
42
+ }
43
+ // Linux/Mac path will be filled in later when those builds exist.
44
+ return path.join(versionDir(ver), 'bin', 'uniner-cli', 'uniner-cli');
45
+ }
46
+
47
+ function skillDir() {
48
+ // Same place Claude Code looks for user-global skills.
49
+ return path.join(homeDir(), '.claude', 'skills', 'uniner-cli');
50
+ }
51
+
52
+ function skillFile() {
53
+ return path.join(skillDir(), 'SKILL.md');
54
+ }
55
+
56
+ function templatePath() {
57
+ return path.join(__dirname, 'SKILL.md.template');
58
+ }
59
+
60
+ function isInstalled(version) {
61
+ try {
62
+ return fs.statSync(realBinaryPath(version)).isFile();
63
+ } catch (_) {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ module.exports = {
69
+ pkg,
70
+ homeDir,
71
+ rootDir,
72
+ versionDir,
73
+ realBinaryPath,
74
+ skillDir,
75
+ skillFile,
76
+ templatePath,
77
+ isInstalled,
78
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "uniner-cli",
3
+ "version": "1.0.0",
4
+ "description": "uniner-cli — RPA automation CLI: browser automation (Playwright/CDP), Excel, OCR, desktop/SAP, crypto/HTTP/email tools. Auto-installs a Claude Code skill on install.",
5
+ "keywords": [
6
+ "rpa",
7
+ "automation",
8
+ "uniner",
9
+ "playwright",
10
+ "excel",
11
+ "ocr",
12
+ "claude-code",
13
+ "ai-agent"
14
+ ],
15
+ "homepage": "https://www.uniner.com/",
16
+ "bugs": {
17
+ "url": "https://www.uniner.com/",
18
+ "email": "support@uniner.com"
19
+ },
20
+ "license": "Proprietary",
21
+ "author": "Uniner Tech",
22
+ "main": "lib/paths.js",
23
+ "bin": {
24
+ "uniner-cli": "bin/uniner-cli.js",
25
+ "uniner-cli-uninstall": "bin/uniner-cli-uninstall.js"
26
+ },
27
+ "scripts": {
28
+ "postinstall": "node scripts/postinstall.js"
29
+ },
30
+ "engines": {
31
+ "node": ">=14"
32
+ },
33
+ "os": [
34
+ "win32"
35
+ ],
36
+ "cpu": [
37
+ "x64"
38
+ ],
39
+ "files": [
40
+ "bin/",
41
+ "lib/",
42
+ "scripts/",
43
+ "README.md",
44
+ "LICENSE"
45
+ ]
46
+ }
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env node
2
+ // postinstall.js — runs after `npm install -g uniner-cli`.
3
+ //
4
+ // What it does:
5
+ // 1. If the binary is already in place at ~/.uniner-cli/<version>/, exit (idempotent).
6
+ // 2. Fetch latest.json from the OSS metadata URL to get portable_url + sha256.
7
+ // 3. Stream-download the matching portable .zip into %TEMP%.
8
+ // - Live progress every 2 seconds.
9
+ // - Up to 3 retries with exponential backoff.
10
+ // 4. Verify SHA-256.
11
+ // 5. Extract via PowerShell's Expand-Archive (no native dep).
12
+ // 6. Render SKILL.md.template with {{INSTALL_PATH}} / {{VERSION}} substituted
13
+ // and write to ~/.claude/skills/uniner-cli/SKILL.md.
14
+ // 7. Print "ready" and how to validate.
15
+ //
16
+ // Failures throw -> non-zero exit -> npm marks install as failed (strict mode).
17
+ // User can then `npm install -g uniner-cli` again to retry cleanly.
18
+
19
+ 'use strict';
20
+
21
+ const fs = require('fs');
22
+ const fsp = require('fs').promises;
23
+ const path = require('path');
24
+ const os = require('os');
25
+ const https = require('https');
26
+ const crypto = require('crypto');
27
+ const { spawnSync } = require('child_process');
28
+
29
+ const {
30
+ pkg,
31
+ versionDir,
32
+ realBinaryPath,
33
+ isInstalled,
34
+ skillDir,
35
+ skillFile,
36
+ templatePath,
37
+ } = require('../lib/paths');
38
+
39
+ // ----------------------------------------------------------------------
40
+ // Configuration
41
+ // ----------------------------------------------------------------------
42
+
43
+ const METADATA_URL =
44
+ 'https://static.uniner.com/soft/editor/uninercli/latest.json';
45
+
46
+ const DOWNLOAD_RETRIES = 3;
47
+ const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 min per attempt
48
+ const PROGRESS_INTERVAL_MS = 2000;
49
+
50
+ // ----------------------------------------------------------------------
51
+ // Logging
52
+ // ----------------------------------------------------------------------
53
+
54
+ function log(msg) { console.log(`[uniner-cli] ${msg}`); }
55
+ function step(msg) { console.log(`[uniner-cli] ==> ${msg}`); }
56
+ function fail(msg) { throw new Error(`[uniner-cli] ${msg}`); }
57
+
58
+ // ----------------------------------------------------------------------
59
+ // HTTP helpers
60
+ // ----------------------------------------------------------------------
61
+
62
+ function getJSON(url) {
63
+ // Cache-bust to dodge any CDN that ignored the no-cache header.
64
+ const sep = url.includes('?') ? '&' : '?';
65
+ const bust = `${sep}t=${Date.now()}`;
66
+ return new Promise((resolve, reject) => {
67
+ const req = https.get(url + bust, { timeout: 30_000 }, res => {
68
+ if (res.statusCode === 301 || res.statusCode === 302) {
69
+ resolve(getJSON(res.headers.location));
70
+ return;
71
+ }
72
+ if (res.statusCode !== 200) {
73
+ reject(new Error(`HTTP ${res.statusCode} for ${url}`));
74
+ return;
75
+ }
76
+ let buf = '';
77
+ res.setEncoding('utf8');
78
+ res.on('data', chunk => { buf += chunk; });
79
+ res.on('end', () => {
80
+ try { resolve(JSON.parse(buf)); }
81
+ catch (e) { reject(new Error(`Bad JSON from ${url}: ${e.message}`)); }
82
+ });
83
+ });
84
+ req.on('timeout', () => { req.destroy(new Error('timeout')); });
85
+ req.on('error', reject);
86
+ });
87
+ }
88
+
89
+ function downloadStream(url, outFile, totalBytesHint) {
90
+ return new Promise((resolve, reject) => {
91
+ const tmp = outFile + '.partial';
92
+ const file = fs.createWriteStream(tmp);
93
+ let received = 0;
94
+ let total = totalBytesHint || 0;
95
+ let lastReport = Date.now();
96
+ let cleanup = () => {};
97
+
98
+ const req = https.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, res => {
99
+ if (res.statusCode === 301 || res.statusCode === 302) {
100
+ file.close(() => fs.unlink(tmp, () => {}));
101
+ resolve(downloadStream(res.headers.location, outFile, totalBytesHint));
102
+ return;
103
+ }
104
+ if (res.statusCode !== 200) {
105
+ file.close(() => fs.unlink(tmp, () => {}));
106
+ reject(new Error(`HTTP ${res.statusCode} for ${url}`));
107
+ return;
108
+ }
109
+
110
+ if (res.headers['content-length']) {
111
+ total = parseInt(res.headers['content-length'], 10);
112
+ }
113
+
114
+ res.on('data', chunk => {
115
+ received += chunk.length;
116
+ file.write(chunk);
117
+
118
+ const now = Date.now();
119
+ if (now - lastReport >= PROGRESS_INTERVAL_MS) {
120
+ const mbDone = (received / 1048576).toFixed(1);
121
+ if (total > 0) {
122
+ const mbTotal = (total / 1048576).toFixed(1);
123
+ const pct = ((received * 100) / total).toFixed(1);
124
+ log(` ${mbDone}/${mbTotal} MB (${pct}%)`);
125
+ } else {
126
+ log(` ${mbDone} MB downloaded`);
127
+ }
128
+ lastReport = now;
129
+ }
130
+ });
131
+
132
+ res.on('end', () => {
133
+ file.end(() => {
134
+ fs.rename(tmp, outFile, err => {
135
+ if (err) reject(err); else resolve(received);
136
+ });
137
+ });
138
+ });
139
+
140
+ cleanup = () => {
141
+ res.destroy();
142
+ file.close(() => fs.unlink(tmp, () => {}));
143
+ };
144
+ });
145
+
146
+ req.on('timeout', () => {
147
+ cleanup();
148
+ req.destroy(new Error('download timed out'));
149
+ });
150
+ req.on('error', err => {
151
+ cleanup();
152
+ reject(err);
153
+ });
154
+ });
155
+ }
156
+
157
+ async function downloadWithRetry(url, outFile, totalBytesHint) {
158
+ let lastErr;
159
+ for (let attempt = 1; attempt <= DOWNLOAD_RETRIES; attempt++) {
160
+ try {
161
+ log(` Download attempt ${attempt}/${DOWNLOAD_RETRIES}...`);
162
+ const bytes = await downloadStream(url, outFile, totalBytesHint);
163
+ log(` Downloaded ${bytes} bytes`);
164
+ return;
165
+ } catch (e) {
166
+ lastErr = e;
167
+ log(` Attempt ${attempt} failed: ${e.message}`);
168
+ try { await fsp.unlink(outFile); } catch (_) {}
169
+ if (attempt < DOWNLOAD_RETRIES) {
170
+ await new Promise(r => setTimeout(r, 5000 * attempt));
171
+ }
172
+ }
173
+ }
174
+ fail(`Download failed after ${DOWNLOAD_RETRIES} attempts: ${lastErr.message}`);
175
+ }
176
+
177
+ // ----------------------------------------------------------------------
178
+ // SHA-256
179
+ // ----------------------------------------------------------------------
180
+
181
+ function sha256OfFile(file) {
182
+ return new Promise((resolve, reject) => {
183
+ const hash = crypto.createHash('sha256');
184
+ const s = fs.createReadStream(file);
185
+ s.on('data', c => hash.update(c));
186
+ s.on('end', () => resolve(hash.digest('hex')));
187
+ s.on('error', reject);
188
+ });
189
+ }
190
+
191
+ // ----------------------------------------------------------------------
192
+ // Extract via PowerShell (no native zip dep)
193
+ // ----------------------------------------------------------------------
194
+
195
+ function extractZipPS(zipFile, destDir) {
196
+ fs.mkdirSync(destDir, { recursive: true });
197
+
198
+ // Expand-Archive is built into Windows PowerShell 5.1+ (Win10/11 default).
199
+ // -Force overwrites any partial leftover from a previous failed install.
200
+ const ps = [
201
+ '-NoProfile',
202
+ '-NonInteractive',
203
+ '-ExecutionPolicy', 'Bypass',
204
+ '-Command',
205
+ `Expand-Archive -LiteralPath '${zipFile}' -DestinationPath '${destDir}' -Force`,
206
+ ];
207
+ const result = spawnSync('powershell.exe', ps, { stdio: 'inherit' });
208
+ if (result.error) {
209
+ fail(`Could not invoke PowerShell to extract: ${result.error.message}`);
210
+ }
211
+ if (result.status !== 0) {
212
+ fail(`Expand-Archive exited with status ${result.status}`);
213
+ }
214
+ }
215
+
216
+ // ----------------------------------------------------------------------
217
+ // SKILL.md template render
218
+ // ----------------------------------------------------------------------
219
+
220
+ function writeSkillMd(installPath, version) {
221
+ const tplPath = templatePath();
222
+ if (!fs.existsSync(tplPath)) {
223
+ log(` [WARN] SKILL.md.template missing at ${tplPath}; skipping skill install.`);
224
+ return;
225
+ }
226
+ let content = fs.readFileSync(tplPath, 'utf8');
227
+ // PowerShell-side StringChangeEx is case-insensitive; here we keep it strict
228
+ // because our template uses exactly these tokens.
229
+ content = content.split('{{INSTALL_PATH}}').join(installPath);
230
+ content = content.split('{{VERSION}}').join(version);
231
+
232
+ const dir = skillDir();
233
+ fs.mkdirSync(dir, { recursive: true });
234
+ fs.writeFileSync(skillFile(), content, 'utf8');
235
+ log(` SKILL.md written to ${skillFile()}`);
236
+ }
237
+
238
+ // ----------------------------------------------------------------------
239
+ // Main
240
+ // ----------------------------------------------------------------------
241
+
242
+ async function main() {
243
+ console.log('');
244
+ console.log('==========================================');
245
+ console.log(' uniner-cli postinstall');
246
+ console.log('==========================================');
247
+
248
+ // We only build a Windows binary right now. Refuse other platforms loudly
249
+ // so users don't end up with a broken install.
250
+ if (process.platform !== 'win32') {
251
+ fail('Only Windows x64 is supported in this release. ' +
252
+ 'Linux/macOS support is on the roadmap.');
253
+ }
254
+ if (process.arch !== 'x64') {
255
+ fail(`Unsupported architecture: ${process.arch}. Only x64 is supported.`);
256
+ }
257
+
258
+ const version = pkg.version;
259
+
260
+ // Skip when a working binary is already there. This makes
261
+ // `npm install -g uniner-cli` idempotent and survives `npm rebuild`.
262
+ if (isInstalled(version)) {
263
+ log(`Already installed at ${realBinaryPath(version)}; skipping download.`);
264
+ log('To force a clean reinstall:');
265
+ log(' npm uninstall -g uniner-cli && npm install -g uniner-cli');
266
+ // Still re-render the skill — it's tiny and lets users edit the template
267
+ // and re-run rebuild to refresh.
268
+ writeSkillMd(versionDir(version), version);
269
+ return;
270
+ }
271
+
272
+ step('Reading version metadata');
273
+ log(` URL: ${METADATA_URL}`);
274
+ const meta = await getJSON(METADATA_URL);
275
+
276
+ const win = meta && meta.windows && meta.windows.x64;
277
+ if (!win || !win.portable_url || !win.portable_sha256) {
278
+ fail('latest.json is missing windows.x64.portable_url / portable_sha256. ' +
279
+ 'The OSS metadata may be from an older release.');
280
+ }
281
+ log(` Latest version: ${meta.version}`);
282
+ log(` Package version (this npm release): ${version}`);
283
+
284
+ if (meta.version !== version) {
285
+ log(` [INFO] Server has ${meta.version} but this npm package is pinned ` +
286
+ `to ${version}. Will fetch the package's own version artifacts ` +
287
+ `from the OSS path that the URL implies.`);
288
+ }
289
+
290
+ step('Downloading portable archive');
291
+ const tmpDir = path.join(os.tmpdir(), 'uniner-cli-npm-install');
292
+ fs.mkdirSync(tmpDir, { recursive: true });
293
+ const zipPath = path.join(tmpDir, path.basename(win.portable_url));
294
+
295
+ log(` From: ${win.portable_url}`);
296
+ log(` To: ${zipPath}`);
297
+ await downloadWithRetry(win.portable_url, zipPath, win.portable_size);
298
+
299
+ step('Verifying SHA-256');
300
+ const actual = await sha256OfFile(zipPath);
301
+ if (actual.toLowerCase() !== win.portable_sha256.toLowerCase()) {
302
+ try { fs.unlinkSync(zipPath); } catch (_) {}
303
+ fail(`SHA-256 mismatch.\n expected: ${win.portable_sha256}\n got: ${actual}`);
304
+ }
305
+ log(' Checksum verified.');
306
+
307
+ step('Extracting');
308
+ const targetDir = versionDir(version);
309
+ log(` Target: ${targetDir}`);
310
+ // If a previous extraction left a half-written tree, blow it away.
311
+ if (fs.existsSync(targetDir)) {
312
+ log(' Removing previous half-extracted tree...');
313
+ fs.rmSync(targetDir, { recursive: true, force: true });
314
+ }
315
+ extractZipPS(zipPath, targetDir);
316
+
317
+ // build_portable.bat puts everything inside an extra `uniner-cli-portable`
318
+ // top-level folder. If we see exactly one subdir and the binary is one
319
+ // level deeper, flatten so realBinaryPath() resolves cleanly.
320
+ const entries = fs.readdirSync(targetDir);
321
+ if (entries.length === 1) {
322
+ const inner = path.join(targetDir, entries[0]);
323
+ if (fs.statSync(inner).isDirectory() &&
324
+ fs.existsSync(path.join(inner, 'bin', 'uniner-cli', 'uniner-cli.exe'))) {
325
+ log(' Flattening extracted tree...');
326
+ for (const item of fs.readdirSync(inner)) {
327
+ fs.renameSync(path.join(inner, item), path.join(targetDir, item));
328
+ }
329
+ fs.rmdirSync(inner);
330
+ }
331
+ }
332
+
333
+ if (!isInstalled(version)) {
334
+ fail(`Extraction completed but the binary is not at ${realBinaryPath(version)}. ` +
335
+ `Inspect ${targetDir} to debug.`);
336
+ }
337
+
338
+ step('Cleaning up');
339
+ try { fs.unlinkSync(zipPath); } catch (_) {}
340
+ try { fs.rmdirSync(tmpDir); } catch (_) {}
341
+ log(' Removed temporary files.');
342
+
343
+ step('Installing Claude Code skill');
344
+ writeSkillMd(targetDir, version);
345
+
346
+ console.log('');
347
+ console.log('==========================================');
348
+ console.log(` uniner-cli ${version} installed`);
349
+ console.log('==========================================');
350
+ console.log('');
351
+ console.log(` Location: ${targetDir}`);
352
+ console.log(` Skill: ${skillFile()}`);
353
+ console.log('');
354
+ console.log(' Validate: uniner-cli --help');
355
+ console.log('');
356
+ }
357
+
358
+ main().catch(err => {
359
+ console.error('');
360
+ console.error(err.message || String(err));
361
+ console.error('');
362
+ console.error('Install failed. To retry:');
363
+ console.error(' npm uninstall -g uniner-cli');
364
+ console.error(' npm install -g uniner-cli');
365
+ console.error('');
366
+ process.exit(1);
367
+ });