@zicolasjac-ai/remote-readonly-ssh 1.1.0 → 1.1.2
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 +38 -6
- package/bin/postinstall.js +27 -0
- package/bin/rrs.js +64 -8
- package/package.json +11 -4
- package/skills/rrs/SKILL.md +115 -0
- package/src/init.js +142 -0
- package/src/setup.js +88 -0
package/README.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# rrs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**CLI and Agent Skill for safe read-only SSH inspection via JumpServer.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
让 AI 助手(opencode / Claude Code / Cursor / Windsurf / Codex 等)通过堡垒机白名单强制地在服务器上做只读查询——技术上零写能力,每次调用(含被拒绝的)写入本地审计日志。
|
|
6
6
|
|
|
7
7
|
## 为什么需要它
|
|
8
8
|
|
|
9
9
|
- **技术强制只读**:命令前缀白名单 + 禁止模式 + 参数防注入 + 目标主机白名单,多层校验。
|
|
10
10
|
- **审计留痕**:每次调用(含被拒绝的记录)写入本地审计日志,可随时审查。
|
|
11
11
|
- **零依赖**:只依赖系统自带 `ssh` 客户端,无 npm 依赖树。
|
|
12
|
+
- **AI Agent 友好**:附带 SKILL.md 可一键安装到 opencode / Claude / Cursor / Windsurf / Codex / OpenHands 等 AI 助手的 skills 目录。
|
|
12
13
|
|
|
13
14
|
## 安装
|
|
14
15
|
|
|
@@ -18,17 +19,28 @@ npm install -g @zicolasjac-ai/remote-readonly-ssh
|
|
|
18
19
|
|
|
19
20
|
要求 Node.js >= 18,系统已装 OpenSSH 客户端(Windows 10+ / Linux / macOS 自带)。
|
|
20
21
|
|
|
22
|
+
> **npx 一行命令**:`npx -y @zicolasjac-ai/remote-readonly-ssh init` 也可直接启动初始化向导(无需先 `npm install`)。
|
|
23
|
+
>
|
|
21
24
|
> 从旧包名 `remote-readonly-ssh`(v1.0.0)升级的用户,请先 `npm uninstall -g remote-readonly-ssh` 再安装,否则会报 `EEXIST: file already exists`。
|
|
22
25
|
|
|
26
|
+
> **postinstall 自动引导**:全局安装完成后,TTY 环境下会自动进入初始化向导;非交互式安装(如 CI)会打印三行下一步提示,不会阻塞安装。
|
|
27
|
+
|
|
23
28
|
## 快速开始
|
|
24
29
|
|
|
25
30
|
```bash
|
|
26
|
-
|
|
27
|
-
#
|
|
28
|
-
rrs list-tools # 查看白名单
|
|
29
|
-
rrs sys-info
|
|
31
|
+
npm install -g @zicolasjac-ai/remote-readonly-ssh # 装完自动进向导
|
|
32
|
+
# 或 npx -y @zicolasjac-ai/remote-readonly-ssh init
|
|
30
33
|
```
|
|
31
34
|
|
|
35
|
+
向导会依次询问:
|
|
36
|
+
|
|
37
|
+
1. 是否覆盖已有 `~/.rrs/config.json`(首次安装跳过此步)
|
|
38
|
+
2. 堡垒机主机 / 端口 / 用户名 / SSH 私钥路径
|
|
39
|
+
3. 目标主机白名单(每行 `ip=堡垒机搜索关键字`)
|
|
40
|
+
4. 是否把 SKILL.md 安装到 opencode / Claude / Cursor 等 AI 助手
|
|
41
|
+
|
|
42
|
+
向导非交互式环境(CI、管道)会静默生成配置模板,不阻塞安装。
|
|
43
|
+
|
|
32
44
|
## 用法
|
|
33
45
|
|
|
34
46
|
```
|
|
@@ -67,6 +79,26 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
|
|
|
67
79
|
|
|
68
80
|
审计日志:`~/.rrs/audit.log`
|
|
69
81
|
|
|
82
|
+
## Agent Skills
|
|
83
|
+
|
|
84
|
+
本工具附带一份 `SKILL.md`,教 AI 助手何时用、怎么用 `rrs`。安装后用 `rrs setup skills` 一键分发到各 agent 的 skills 目录:
|
|
85
|
+
|
|
86
|
+
| `--agent` | 写入路径 |
|
|
87
|
+
|-----------|----------|
|
|
88
|
+
| `opencode` | `~/.config/opencode/skills/rrs/SKILL.md` |
|
|
89
|
+
| `claude` / `cursor` / `windsurf` / `codex` | `~/.claude/skills/rrs/SKILL.md`(Claude skills 兼容) |
|
|
90
|
+
| `agents` / `openhands` | `~/.agents/skills/rrs/SKILL.md` |
|
|
91
|
+
| `all`(默认) | 上述三类目录都写 |
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
rrs setup skills # 安装到全部三类目录
|
|
95
|
+
rrs setup skills --agent opencode # 只写 opencode
|
|
96
|
+
rrs setup skills --dry-run # 仅预览目标路径
|
|
97
|
+
rrs setup uninstall skills # 从全部目录移除
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
卸载 npm 包时会自动尝试清理这些 skill 文件(`preuninstall` 钩子,失败不影响卸载)。
|
|
101
|
+
|
|
70
102
|
## 开发
|
|
71
103
|
|
|
72
104
|
```bash
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const initWizard = require("../src/init");
|
|
5
|
+
const setupSkill = require("../src/setup");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
9
|
+
|
|
10
|
+
(async () => {
|
|
11
|
+
const tty = process.stdin.isTTY && process.stdout.isTTY;
|
|
12
|
+
if (tty) {
|
|
13
|
+
try {
|
|
14
|
+
await initWizard.runInteractive({ packageRoot: PACKAGE_ROOT });
|
|
15
|
+
} catch (e) {
|
|
16
|
+
console.error(`[rrs postinstall] 向导失败: ${e.message}`);
|
|
17
|
+
console.error("可稍后手动执行: rrs init");
|
|
18
|
+
}
|
|
19
|
+
} else {
|
|
20
|
+
console.log("");
|
|
21
|
+
console.log("rrs 安装完成。下一步:");
|
|
22
|
+
console.log(" rrs init # 交互式初始化 + 安装 AI skill(推荐)");
|
|
23
|
+
console.log(" rrs setup skills # 仅安装 AI skill 到 opencode / claude 等");
|
|
24
|
+
console.log(" rrs list-tools # 查看白名单与配置");
|
|
25
|
+
console.log("");
|
|
26
|
+
}
|
|
27
|
+
})();
|
package/bin/rrs.js
CHANGED
|
@@ -8,12 +8,14 @@ const { JumpShell, cleanAnsi } = require("../src/jumpshell");
|
|
|
8
8
|
const {
|
|
9
9
|
validateCmd, validatePath, validateHost, validateArg, validateBin,
|
|
10
10
|
} = require("../src/whitelist");
|
|
11
|
+
const setupSkill = require("../src/setup");
|
|
12
|
+
const initWizard = require("../src/init");
|
|
11
13
|
|
|
12
14
|
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
13
15
|
|
|
14
16
|
function printUsage() {
|
|
15
17
|
const cmds = [
|
|
16
|
-
["init", "
|
|
18
|
+
["init", "交互式初始化向导(TTY 下)或生成配置模板"],
|
|
17
19
|
["list-tools", "查看工具、白名单、配置"],
|
|
18
20
|
["sys-info", "主机名/OS/内核/uptime"],
|
|
19
21
|
["proc <pattern>", "进程查询(防注入参数校验)"],
|
|
@@ -22,35 +24,88 @@ function printUsage() {
|
|
|
22
24
|
["dir <path>", "列目录(路径白名单)"],
|
|
23
25
|
["bin-version <bin>", "查二进制版本(二进制白名单)"],
|
|
24
26
|
["exec-only <cmd>", "通用只读命令(前缀白名单+禁止模式)"],
|
|
27
|
+
["setup skills [--agent <n>]", "把 SKILL.md 安装到 opencode/claude/agents 等目录"],
|
|
28
|
+
["setup uninstall skills [--agent <n>]", "从上述目录移除 skill"],
|
|
25
29
|
];
|
|
26
|
-
console.log("rrs —
|
|
27
|
-
console.log("用法: rrs <工具> [参数] [--host <ip>] [--config <file>] [--json]
|
|
30
|
+
console.log("rrs — CLI and Agent Skill for safe read-only SSH inspection\n");
|
|
31
|
+
console.log("用法: rrs <工具> [参数] [--host <ip>] [--config <file>] [--json]");
|
|
32
|
+
console.log(" rrs setup skills [--agent opencode|claude|cursor|windsurf|codex|openhands|agents|all] [--dry-run]\n");
|
|
28
33
|
console.log("选项: --json 以 JSON 输出结果(便于脚本/AI 调用); -v/--version 查看版本\n");
|
|
29
34
|
for (const [k, v] of cmds) {
|
|
30
|
-
console.log(` ${k.padEnd(
|
|
35
|
+
console.log(` ${k.padEnd(40)} ${v}`);
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
function parseArgs(argv) {
|
|
35
|
-
const opts = {
|
|
40
|
+
const opts = {
|
|
41
|
+
mode: "tool",
|
|
42
|
+
tool: null,
|
|
43
|
+
arg: null,
|
|
44
|
+
setupCmd: null,
|
|
45
|
+
agent: "all",
|
|
46
|
+
dryRun: false,
|
|
47
|
+
host: null,
|
|
48
|
+
config: null,
|
|
49
|
+
version: false,
|
|
50
|
+
json: false,
|
|
51
|
+
};
|
|
36
52
|
const rest = [];
|
|
37
53
|
for (let i = 0; i < argv.length; i++) {
|
|
38
54
|
const a = argv[i];
|
|
39
55
|
if (a === "--host") opts.host = argv[++i];
|
|
40
56
|
else if (a === "--config") opts.config = argv[++i];
|
|
41
|
-
else if (a === "
|
|
57
|
+
else if (a === "--agent") opts.agent = argv[++i];
|
|
58
|
+
else if (a === "--dry-run") opts.dryRun = true;
|
|
59
|
+
else if (a === "-h" || a === "--help") { opts.mode = "tool"; opts.tool = "help"; }
|
|
42
60
|
else if (a === "-v" || a === "--version") opts.version = true;
|
|
43
61
|
else if (a === "--json") opts.json = true;
|
|
44
62
|
else rest.push(a);
|
|
45
63
|
}
|
|
64
|
+
if (rest[0] === "setup") {
|
|
65
|
+
opts.mode = "setup";
|
|
66
|
+
if (rest[1] === "uninstall") {
|
|
67
|
+
opts.setupCmd = "uninstall";
|
|
68
|
+
if (rest[2] === "skills") opts.setupTarget = "skills";
|
|
69
|
+
} else if (rest[1] === "skills") {
|
|
70
|
+
opts.setupCmd = "install";
|
|
71
|
+
opts.setupTarget = "skills";
|
|
72
|
+
}
|
|
73
|
+
return opts;
|
|
74
|
+
}
|
|
46
75
|
opts.tool = opts.tool || rest[0] || "help";
|
|
47
76
|
opts.arg = rest[1] || null;
|
|
48
77
|
return opts;
|
|
49
78
|
}
|
|
50
79
|
|
|
80
|
+
function runSetup(opts) {
|
|
81
|
+
const agent = opts.agent || "all";
|
|
82
|
+
if (!opts.setupTarget || opts.setupTarget !== "skills") {
|
|
83
|
+
console.error("用法: rrs setup skills [--agent all|opencode|claude|cursor|windsurf|codex|openhands|agents] [--dry-run]");
|
|
84
|
+
console.error(" rrs setup uninstall skills [--agent ...] [--dry-run]");
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
let results;
|
|
88
|
+
try {
|
|
89
|
+
results = opts.setupCmd === "uninstall"
|
|
90
|
+
? setupSkill.uninstall({ agent, dryRun: opts.dryRun })
|
|
91
|
+
: setupSkill.install(PACKAGE_ROOT, { agent, dryRun: opts.dryRun });
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.error(`[setup] 错误: ${e.message}`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
const verb = opts.setupCmd === "uninstall" ? "卸载" : "安装";
|
|
97
|
+
console.log(`${verb} skill (agent=${agent}):`);
|
|
98
|
+
for (const r of results) {
|
|
99
|
+
const status = r.action.startsWith("would-") ? "将" : "已";
|
|
100
|
+
console.log(` [${r.agent}] ${status}${r.action.replace("would-", "")} ${r.dir}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
51
104
|
function initConfig() {
|
|
105
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
106
|
+
return initWizard.runInteractive();
|
|
107
|
+
}
|
|
52
108
|
const dir = configDir();
|
|
53
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
54
109
|
const dest = path.join(dir, "config.json");
|
|
55
110
|
if (fs.existsSync(dest)) {
|
|
56
111
|
console.log(`配置已存在: ${dest}(未覆盖,如需重置请手动删除)`);
|
|
@@ -121,6 +176,7 @@ async function main() {
|
|
|
121
176
|
console.log(require(path.join(PACKAGE_ROOT, "package.json")).version);
|
|
122
177
|
return;
|
|
123
178
|
}
|
|
179
|
+
if (opts.mode === "setup") { runSetup(opts); return; }
|
|
124
180
|
if (opts.tool === "help") { printUsage(); return; }
|
|
125
181
|
if (opts.tool === "init") { initConfig(); return; }
|
|
126
182
|
|
|
@@ -165,4 +221,4 @@ if (require.main === module) {
|
|
|
165
221
|
main();
|
|
166
222
|
}
|
|
167
223
|
|
|
168
|
-
module.exports = { parseArgs, buildCmds, printUsage };
|
|
224
|
+
module.exports = { parseArgs, buildCmds, printUsage, runSetup, initConfig };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zicolasjac-ai/remote-readonly-ssh",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "只读 SSH 运维 CLI
|
|
3
|
+
"version": "1.1.2",
|
|
4
|
+
"description": "只读 SSH 运维 CLI 与 Agent Skill:通过 JumpServer 堡垒机白名单强制执行只读查询;附 SKILL.md 让 opencode / Claude Code / Cursor 等 AI 助手自动发现并正确调用。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
7
7
|
"rrs": "bin/rrs.js"
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin/",
|
|
12
12
|
"src/",
|
|
13
|
+
"skills/",
|
|
13
14
|
"config.example.json",
|
|
14
15
|
"README.md",
|
|
15
16
|
"LICENSE"
|
|
@@ -26,10 +27,16 @@
|
|
|
26
27
|
"whitelist",
|
|
27
28
|
"audit",
|
|
28
29
|
"security",
|
|
29
|
-
"cli"
|
|
30
|
+
"cli",
|
|
31
|
+
"agent-skill",
|
|
32
|
+
"opencode",
|
|
33
|
+
"claude",
|
|
34
|
+
"ai"
|
|
30
35
|
],
|
|
31
36
|
"dependencies": {},
|
|
32
37
|
"scripts": {
|
|
33
|
-
"test": "node --test"
|
|
38
|
+
"test": "node --test",
|
|
39
|
+
"postinstall": "node bin/postinstall.js",
|
|
40
|
+
"preuninstall": "node -e \"try { require('./src/setup').uninstall({agent:'all'}) } catch(_) {}\""
|
|
34
41
|
}
|
|
35
42
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rrs
|
|
3
|
+
description: 通过 JumpServer 堡垒机对服务器做白名单强制的只读运维查询(sys-info / proc / ports / dir / read-file / bin-version / exec-only)。技术强制只读、命令前缀白名单 + 禁止模式 + 路径白名单 + 主机白名单多层校验,每次调用(含被拒绝的)都写入本地审计日志。AI Agent 需要做"查看服务器现状"这类操作时调用此 skill;它不会写入任何远程状态。
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: opencode, claude-code, cursor, windsurf, openhands, codex
|
|
6
|
+
metadata:
|
|
7
|
+
audience: ai-agents
|
|
8
|
+
category: ops
|
|
9
|
+
safe: read-only
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# rrs — Read-Only SSH Inspection Skill
|
|
13
|
+
|
|
14
|
+
`rrs`(remote-read-only-ssh)是一个面向 AI Agent 的只读 SSH 运维工具。它通过 JumpServer 风格的堡垒机在白名单目标主机上执行**白名单强制**的只读查询。每次调用(含被拒绝的)都写入本地审计日志。
|
|
15
|
+
|
|
16
|
+
## When to use me
|
|
17
|
+
|
|
18
|
+
当你需要从服务器**获取信息**而不是**修改状态**时调用。典型场景:
|
|
19
|
+
|
|
20
|
+
- 排查时拉主机名 / OS / 内核 / uptime
|
|
21
|
+
- 查某个进程是否在跑(`proc nginx`)
|
|
22
|
+
- 看监听端口(`ports 8080`)
|
|
23
|
+
- 读配置文件(`read-file /etc/nginx/nginx.conf`)
|
|
24
|
+
- 列目录结构(`dir /var/log`)
|
|
25
|
+
- 查二进制版本(`bin-version nginx`)
|
|
26
|
+
- 跑一段自定义只读命令(`exec-only "df -h | head"`)
|
|
27
|
+
|
|
28
|
+
**不要**用它做任何写操作——rm / mv / cp / vi / sed -i / systemctl / 重定向到文件 等一律被拒绝。
|
|
29
|
+
|
|
30
|
+
## Setup(用户一次性)
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install -g @zicolasjac-ai/remote-readonly-ssh
|
|
34
|
+
rrs init # 生成配置模板 ~/.rrs/config.json
|
|
35
|
+
# 编辑配置: jump_server / private_key_path / target_hosts
|
|
36
|
+
rrs setup skills # 把本 skill 安装到 opencode / claude / agents 等目录
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**始终加 `--json`**,便于解析。
|
|
46
|
+
|
|
47
|
+
### Tools
|
|
48
|
+
|
|
49
|
+
| 工具 | 用途 | 示例 |
|
|
50
|
+
|------|------|------|
|
|
51
|
+
| `sys-info` | 主机名 / OS / 内核 / uptime | `rrs sys-info --json` |
|
|
52
|
+
| `proc <pattern>` | 进程查询(参数严格校验) | `rrs proc nginx --json` |
|
|
53
|
+
| `ports [filter]` | 监听端口 | `rrs ports 80 --json` |
|
|
54
|
+
| `read-file <path>` | 读文件(路径白名单) | `rrs read-file /etc/nginx/nginx.conf --json` |
|
|
55
|
+
| `dir <path>` | 列目录 | `rrs dir /var/log --json` |
|
|
56
|
+
| `bin-version <bin>` | 查二进制版本/编译参数 | `rrs bin-version nginx --json` |
|
|
57
|
+
| `exec-only <cmd>` | 通用只读命令(白名单校验) | `rrs exec-only "df -h" --json` |
|
|
58
|
+
| `list-tools` | 查看白名单 / 配置 | `rrs list-tools --json` |
|
|
59
|
+
| `init` | 生成配置模板 | `rrs init` |
|
|
60
|
+
|
|
61
|
+
### Options
|
|
62
|
+
|
|
63
|
+
- `--host <ip>`:指定目标主机(必须存在于 `target_hosts` 白名单)
|
|
64
|
+
- `--config <file>`:使用自定义配置文件
|
|
65
|
+
- `--json`:以 JSON 输出
|
|
66
|
+
- `-v` / `--version`:查看版本
|
|
67
|
+
- `-h` / `--help`:查看用法
|
|
68
|
+
|
|
69
|
+
### Return shape
|
|
70
|
+
|
|
71
|
+
成功:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{"ok":true,"tool":"sys-info","host":"10.0.0.1","output":"..."}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
拒绝(参数非法 / 敏感路径 / 命令不在白名单):
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{"ok":false,"tool":"proc","host":"","error":"拒绝: 进程名 含非法字符 [a;b]","kind":"拒绝"}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
错误(连接 / 鉴权 / 超时):
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{"ok":false,"tool":"sys-info","host":"10.0.0.1","error":"等待命令执行完成超时: ...","kind":"错误"}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
退出码:`0`=成功 / `2`=拒绝 / `3`=配置错误 / `1`=其他错误。
|
|
90
|
+
|
|
91
|
+
## Hard rules(不要试图绕过)
|
|
92
|
+
|
|
93
|
+
1. **永远不要**用 `exec-only` 跑写命令(`rm`、`mv`、`sed -i`、`>`、`tee` 等都被白名单拒绝)
|
|
94
|
+
2. **永远不要**用 `read-file` 读 `blocked_paths`(`/etc/passwd`、`/etc/shadow`、`~/.ssh/id_*` 等)
|
|
95
|
+
3. **永远不要**指定未在 `target_hosts` 注册的 IP
|
|
96
|
+
4. **不要**试图把 `;` `&&` `|` `>` `$()` 拼到参数里——所有路径和参数都做严格字符集校验
|
|
97
|
+
|
|
98
|
+
被拒绝的尝试一样会进审计日志。
|
|
99
|
+
|
|
100
|
+
## Audit
|
|
101
|
+
|
|
102
|
+
每次调用(含拒绝)追加写入 `~/.rrs/audit.log`:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
[巡检记录] 2026-08-18 10:30:00
|
|
106
|
+
主机: 10.0.0.1
|
|
107
|
+
巡检项: sys-info
|
|
108
|
+
结果: 成功
|
|
109
|
+
命令明细:
|
|
110
|
+
1. echo ===HOST===; hostname; whoami; id
|
|
111
|
+
读取主机标识与权限
|
|
112
|
+
...
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
如需让审计同时记录命令输出摘要,在 `~/.rrs/config.json` 设置 `"audit": { "include_output": true }`。
|
package/src/init.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const readline = require("node:readline/promises");
|
|
7
|
+
const setupSkill = require("./setup");
|
|
8
|
+
|
|
9
|
+
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
10
|
+
|
|
11
|
+
function templatePath() {
|
|
12
|
+
return path.join(PACKAGE_ROOT, "config.example.json");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readTemplate() {
|
|
16
|
+
return JSON.parse(fs.readFileSync(templatePath(), "utf8"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeTemplate(destDir) {
|
|
20
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
21
|
+
const dest = path.join(destDir, "config.json");
|
|
22
|
+
if (fs.existsSync(dest)) return { dest, existed: true };
|
|
23
|
+
fs.writeFileSync(dest, JSON.stringify(readTemplate(), null, 2), "utf8");
|
|
24
|
+
return { dest, existed: false };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseTargets(text) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const line of String(text || "").split(/\r?\n/)) {
|
|
30
|
+
const t = line.trim();
|
|
31
|
+
if (!t || t.startsWith("#")) continue;
|
|
32
|
+
const eq = t.indexOf("=");
|
|
33
|
+
if (eq <= 0) continue;
|
|
34
|
+
const ip = t.slice(0, eq).trim();
|
|
35
|
+
const kw = t.slice(eq + 1).trim();
|
|
36
|
+
if (ip && kw) out[ip] = kw;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function applyAnswers(cfg, answers) {
|
|
42
|
+
const a = answers || {};
|
|
43
|
+
if (a.jumpHost) cfg.jump_server.host = a.jumpHost;
|
|
44
|
+
if (a.jumpPort) cfg.jump_server.port = Number(a.jumpPort) || cfg.jump_server.port;
|
|
45
|
+
if (a.jumpUser) cfg.jump_server.username = a.jumpUser;
|
|
46
|
+
if (a.privateKeyPath) cfg.private_key_path = a.privateKeyPath;
|
|
47
|
+
if (a.targetHosts && Object.keys(a.targetHosts).length) cfg.target_hosts = a.targetHosts;
|
|
48
|
+
return cfg;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function defaultAnswersFor(cfg) {
|
|
52
|
+
return {
|
|
53
|
+
jumpHost: cfg.jump_server && cfg.jump_server.host ? "" : "",
|
|
54
|
+
jumpPort: cfg.jump_server && cfg.jump_server.port ? String(cfg.jump_server.port) : "22",
|
|
55
|
+
jumpUser: cfg.jump_server && cfg.jump_server.username ? cfg.jump_server.username : "",
|
|
56
|
+
privateKeyPath: cfg.private_key_path && !cfg.private_key_path.includes("YOURNAME") ? cfg.private_key_path : "",
|
|
57
|
+
targetHosts: cfg.target_hosts || {},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function collectAnswers(rl, cfg, prompt) {
|
|
62
|
+
const ask = async (q, def) => {
|
|
63
|
+
const label = def ? `${q} [${def}]` : q;
|
|
64
|
+
const ans = (await rl.question(`${label}: `)).trim();
|
|
65
|
+
return ans || def || "";
|
|
66
|
+
};
|
|
67
|
+
const usePrompt = prompt && typeof prompt === "object";
|
|
68
|
+
const ans = {};
|
|
69
|
+
ans.jumpHost = usePrompt && prompt.jumpHost
|
|
70
|
+
? await prompt.jumpHost(ask, cfg)
|
|
71
|
+
: await ask("堡垒机主机 IP / 域名", "");
|
|
72
|
+
if (!ans.jumpHost) {
|
|
73
|
+
throw new Error("跳过:未填写堡垒机主机。请稍后编辑 " + path.join(os.homedir(), ".rrs", "config.json"));
|
|
74
|
+
}
|
|
75
|
+
ans.jumpPort = usePrompt && prompt.jumpPort
|
|
76
|
+
? await prompt.jumpPort(ask, cfg)
|
|
77
|
+
: await ask("堡垒机端口", "22");
|
|
78
|
+
ans.jumpUser = usePrompt && prompt.jumpUser
|
|
79
|
+
? await prompt.jumpUser(ask, cfg)
|
|
80
|
+
: await ask("堡垒机用户名", "");
|
|
81
|
+
ans.privateKeyPath = usePrompt && prompt.privateKeyPath
|
|
82
|
+
? await prompt.privateKeyPath(ask, cfg)
|
|
83
|
+
: await ask("SSH 私钥绝对路径", path.join(os.homedir(), ".ssh", "id_rsa"));
|
|
84
|
+
const targetText = usePrompt && prompt.targetHosts
|
|
85
|
+
? await prompt.targetHosts(ask, cfg)
|
|
86
|
+
: await ask("目标主机(每行 ip=堡垒机搜索关键字,空行结束)", "");
|
|
87
|
+
ans.targetHosts = parseTargets(targetText);
|
|
88
|
+
return ans;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function runInteractive({ input = process.stdin, output = process.stdout, packageRoot = PACKAGE_ROOT, prompt, installSkills = true, rlFactory } = {}) {
|
|
92
|
+
const destDir = path.join(os.homedir(), ".rrs");
|
|
93
|
+
const dest = path.join(destDir, "config.json");
|
|
94
|
+
const existed = fs.existsSync(dest);
|
|
95
|
+
|
|
96
|
+
const rl = rlFactory
|
|
97
|
+
? rlFactory()
|
|
98
|
+
: readline.createInterface({ input, output });
|
|
99
|
+
try {
|
|
100
|
+
let overwrite = "n";
|
|
101
|
+
if (existed) {
|
|
102
|
+
const ans = (await rl.question(`检测到已存在配置 ${dest}\n (n) 跳过配置,仅安装 skill (b) 备份后重写 (y) 直接覆盖 [n]: `)).trim().toLowerCase();
|
|
103
|
+
overwrite = ans || "n";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!existed || overwrite === "y" || overwrite === "b") {
|
|
107
|
+
if (existed && overwrite === "b") {
|
|
108
|
+
fs.copyFileSync(dest, dest + ".bak");
|
|
109
|
+
}
|
|
110
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
111
|
+
const cfg = readTemplate();
|
|
112
|
+
const answers = await collectAnswers(rl, defaultAnswersFor(cfg), prompt);
|
|
113
|
+
const filled = applyAnswers(cfg, answers);
|
|
114
|
+
fs.writeFileSync(dest, JSON.stringify(filled, null, 2), "utf8");
|
|
115
|
+
output.write(`已写入配置: ${dest}\n`);
|
|
116
|
+
} else {
|
|
117
|
+
output.write(`保留现有配置: ${dest}\n`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let installedSkills = false;
|
|
121
|
+
if (installSkills) {
|
|
122
|
+
const ans = (await rl.question("安装 AI skill 到 opencode / claude / agents 目录? [Y/n]: ")).trim().toLowerCase();
|
|
123
|
+
const install = ans || "y";
|
|
124
|
+
if (install !== "n") {
|
|
125
|
+
const results = setupSkill.install(packageRoot, { agent: "all" });
|
|
126
|
+
for (const r of results) {
|
|
127
|
+
output.write(` [${r.agent}] ${r.action === "written" ? "已安装" : r.action} ${r.dir}\n`);
|
|
128
|
+
}
|
|
129
|
+
installedSkills = true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
output.write("\n下一步:\n");
|
|
134
|
+
output.write(" rrs list-tools # 查看白名单与配置\n");
|
|
135
|
+
output.write(" rrs sys-info --json # 试跑一次只读巡检\n");
|
|
136
|
+
return { dest, overwrite, installedSkills };
|
|
137
|
+
} finally {
|
|
138
|
+
if (!rlFactory) rl.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { runInteractive, writeTemplate, readTemplate, parseTargets, applyAnswers, defaultAnswersFor, templatePath };
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
|
|
7
|
+
const SKILL_NAME = "rrs";
|
|
8
|
+
|
|
9
|
+
const ALIAS_TO_GROUP = {
|
|
10
|
+
opencode: ["opencode"],
|
|
11
|
+
claude: ["claude"],
|
|
12
|
+
agents: ["agents"],
|
|
13
|
+
codex: ["claude"],
|
|
14
|
+
cursor: ["claude"],
|
|
15
|
+
windsurf: ["claude"],
|
|
16
|
+
openhands: ["agents"],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const RELATIVE_DIRS = {
|
|
20
|
+
opencode: [".config", "opencode", "skills"],
|
|
21
|
+
claude: [".claude", "skills"],
|
|
22
|
+
agents: [".agents", "skills"],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function listTargets(agent) {
|
|
26
|
+
const a = (agent || "all").toLowerCase();
|
|
27
|
+
if (a === "all") return ["opencode", "claude", "agents"];
|
|
28
|
+
const groups = ALIAS_TO_GROUP[a];
|
|
29
|
+
if (!groups) throw new Error(`未知 --agent [${agent}],支持: all / opencode / claude / cursor / windsurf / codex / openhands / agents`);
|
|
30
|
+
return groups;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function targetFile(agent, home) {
|
|
34
|
+
const segs = RELATIVE_DIRS[agent];
|
|
35
|
+
if (!segs) throw new Error(`内部错误: 未知 agent ${agent}`);
|
|
36
|
+
return path.join(home, ...segs, SKILL_NAME, "SKILL.md");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function skillSrc(packageRoot) {
|
|
40
|
+
return path.join(packageRoot, "skills", SKILL_NAME, "SKILL.md");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function install(packageRoot, opts = {}) {
|
|
44
|
+
const agent = opts.agent || "all";
|
|
45
|
+
const dryRun = !!opts.dryRun;
|
|
46
|
+
const home = opts.home || os.homedir();
|
|
47
|
+
const src = skillSrc(packageRoot);
|
|
48
|
+
if (!fs.existsSync(src)) throw new Error(`缺少 skill 资源: ${src}`);
|
|
49
|
+
const targets = listTargets(agent);
|
|
50
|
+
const results = [];
|
|
51
|
+
for (const t of targets) {
|
|
52
|
+
const dest = targetFile(t, home);
|
|
53
|
+
if (dryRun) {
|
|
54
|
+
results.push({ agent: t, dir: dest, action: "would-write", exists: fs.existsSync(dest) });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
58
|
+
fs.writeFileSync(dest, fs.readFileSync(src, "utf8"), "utf8");
|
|
59
|
+
results.push({ agent: t, dir: dest, action: "written", exists: true });
|
|
60
|
+
}
|
|
61
|
+
return results;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function uninstall(opts = {}) {
|
|
65
|
+
const agent = opts.agent || "all";
|
|
66
|
+
const dryRun = !!opts.dryRun;
|
|
67
|
+
const home = opts.home || os.homedir();
|
|
68
|
+
const targets = listTargets(agent);
|
|
69
|
+
const results = [];
|
|
70
|
+
for (const t of targets) {
|
|
71
|
+
const dest = targetFile(t, home);
|
|
72
|
+
const dir = path.dirname(dest);
|
|
73
|
+
if (!fs.existsSync(dest)) {
|
|
74
|
+
results.push({ agent: t, dir: dest, action: "missing", exists: false });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (dryRun) {
|
|
78
|
+
results.push({ agent: t, dir: dest, action: "would-delete", exists: true });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
fs.unlinkSync(dest);
|
|
82
|
+
try { fs.rmdirSync(dir); } catch (_) {}
|
|
83
|
+
results.push({ agent: t, dir: dest, action: "deleted", exists: false });
|
|
84
|
+
}
|
|
85
|
+
return results;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { install, uninstall, listTargets, targetFile, skillSrc, ALIAS_TO_GROUP, SKILL_NAME };
|