@zicolasjac-ai/remote-readonly-ssh 1.1.5 → 1.1.7

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 CHANGED
@@ -90,19 +90,29 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
90
90
 
91
91
  本工具附带一份 `SKILL.md`,教 AI 助手何时用、怎么用 `rrs`。安装后用 `rrs setup skills` 一键分发到各 agent 的 skills 目录:
92
92
 
93
- | `--agent` | 写入路径 |
94
- |-----------|----------|
95
- | `opencode` | `~/.config/opencode/skills/rrs/SKILL.md` |
96
- | `claude` / `cursor` / `windsurf` / `codex` | `~/.claude/skills/rrs/SKILL.md`(Claude skills 兼容) |
97
- | `agents` / `openhands` | `~/.agents/skills/rrs/SKILL.md` |
98
- | `all`(默认) | 上述三类目录都写 |
93
+ | `--agent` | 写入方式 | 路径 |
94
+ |-----------|----------|------|
95
+ | `agents`(canonical) | **实体文件** | `~/.agents/skills/remote-readonly-ssh/SKILL.md` |
96
+ | `opencode` | 软链接 → agents | `~/.config/opencode/skills/remote-readonly-ssh/SKILL.md` |
97
+ | `claude` / `claudecode` / `cursor` / `windsurf` / `codex` | 软链接 → agents | `~/.claude/skills/remote-readonly-ssh/SKILL.md` |
98
+ | `qoder` | 软链接 → agents | `~/.qoder/skills/remote-readonly-ssh/SKILL.md` |
99
+ | `all`(默认) | 实体 + 上述 3 个软链接 | 一行命令装齐 |
100
+
101
+ `rrs setup skills` 默认 `agent=all`(不询问,直接装),输出一行简明提示:
102
+
103
+ ```
104
+ rrs skill 已安装: agents (实体), opencode/claude/qoder (软链接)
105
+ ```
106
+
107
+ > **Windows 注意**:普通用户缺少 `SeCreateSymbolicLinkPrivilege`,软链接创建会被 OS 拒绝;rrs 会自动 fallback 为复制,并提示"Windows 已 fallback 为复制(请开启开发者模式以启用真软链接)"。开启开发者模式后重新跑 `rrs setup skills` 即可使用真软链接。
99
108
 
100
109
  ```bash
101
- rrs setup skills # 安装到全部三类目录
110
+ rrs setup skills # 一行命令装齐(实体 + 3 个软链接)
102
111
  npx -y @zicolasjac-ai/remote-readonly-ssh@latest setup skills # 全局未装时用 npx
103
- rrs setup skills --agent opencode # 只写 opencode
104
- rrs setup skills --dry-run # 仅预览目标路径
105
- rrs setup uninstall skills # 从全部目录移除
112
+ rrs setup skills --agent opencode # 只装 opencode 链接(agents 实体也自动建)
113
+ rrs setup skills --agent qoder # 只装 qoder 链接
114
+ rrs setup skills --dry-run # 仅预览将要创建的目标
115
+ rrs setup uninstall skills # 全部移除(实体 + 软链接)
106
116
  ```
107
117
 
108
118
  卸载 npm 包时会自动尝试清理这些 skill 文件(`preuninstall` 钩子,失败不影响卸载)。
package/bin/rrs.js CHANGED
@@ -24,8 +24,8 @@ function printUsage() {
24
24
  ["dir <path>", "列目录(路径白名单)"],
25
25
  ["bin-version <bin>", "查二进制版本(二进制白名单)"],
26
26
  ["exec-only <cmd>", "通用只读命令(前缀白名单+禁止模式)"],
27
- ["setup skills [--agent <n>]", "把 SKILL.md 安装到 opencode/claude/agents 等目录"],
28
- ["setup uninstall skills [--agent <n>]", "从上述目录移除 skill"],
27
+ ["setup skills [--agent <n>]", "把 SKILL.md 装到 agents(实体) + opencode/claude/qoder(软链接)"],
28
+ ["setup uninstall skills [--agent <n>]", "移除上述目录的 skill"],
29
29
  ];
30
30
  console.log("rrs — CLI and Agent Skill for safe read-only SSH inspection\n");
31
31
  console.log("用法: rrs <工具> [参数] [--host <ip>] [--config <file>] [--json]");
@@ -80,7 +80,7 @@ function parseArgs(argv) {
80
80
  function runSetup(opts) {
81
81
  const agent = opts.agent || "all";
82
82
  if (!opts.setupTarget || opts.setupTarget !== "skills") {
83
- console.error("用法: rrs setup skills [--agent all|opencode|claude|cursor|windsurf|codex|openhands|agents] [--dry-run]");
83
+ console.error("用法: rrs setup skills [--agent all|opencode|claude|claudecode|cursor|windsurf|codex|qoder|agents|openhands] [--dry-run]");
84
84
  console.error(" rrs setup uninstall skills [--agent ...] [--dry-run]");
85
85
  process.exit(1);
86
86
  }
@@ -93,11 +93,34 @@ function runSetup(opts) {
93
93
  console.error(`[setup] 错误: ${e.message}`);
94
94
  process.exit(1);
95
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}`);
96
+
97
+ if (opts.dryRun) {
98
+ console.log(opts.setupCmd === "uninstall" ? "将卸载:" : "将安装:");
99
+ for (const r of results) {
100
+ console.log(` ${r.action.replace("would-", "将")} ${r.dir}`);
101
+ }
102
+ return;
103
+ }
104
+
105
+ // 默认输出:一行简明
106
+ const canonical = results.find((r) => r.agent === "agents");
107
+ const links = results.filter((r) => r.agent !== "agents");
108
+ const linkNames = links.map((r) => r.agent).join("/");
109
+ const anyFallback = links.some((r) => r.fallback);
110
+ const verb = opts.setupCmd === "uninstall" ? "已卸载" : "已安装";
111
+
112
+ if (opts.setupCmd === "install") {
113
+ if (canonical && links.length > 0) {
114
+ console.log(`rrs skill ${verb}: agents (实体), ${linkNames} (软链接)${anyFallback ? " — Windows 已自动 fallback 为复制(请开启开发者模式以启用真软链接)" : ""}`);
115
+ } else if (canonical) {
116
+ console.log(`rrs skill ${verb}: agents (实体)`);
117
+ } else if (links.length > 0) {
118
+ console.log(`rrs skill ${verb}: ${linkNames} (软链接)${anyFallback ? " — Windows 已自动 fallback 为复制" : ""}`);
119
+ }
120
+ } else {
121
+ const removed = results.filter((r) => r.action !== "missing").map((r) => r.agent).join("/");
122
+ if (removed) console.log(`rrs skill ${verb}: ${removed}`);
123
+ else console.log(`rrs skill 未找到任何已安装项`);
101
124
  }
102
125
  }
103
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zicolasjac-ai/remote-readonly-ssh",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "description": "只读 SSH 运维 CLI 与 Agent Skill:通过 JumpServer 堡垒机白名单强制执行只读查询;附 SKILL.md 让 opencode / Claude Code / Cursor 等 AI 助手自动发现并正确调用。",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  ---
2
- name: rrs
2
+ name: remote-readonly-ssh
3
3
  description: 通过 JumpServer 堡垒机对服务器做白名单强制的只读运维查询(sys-info / proc / ports / dir / read-file / bin-version / exec-only)。技术强制只读、命令前缀白名单 + 禁止模式 + 路径白名单 + 主机白名单多层校验,每次调用(含被拒绝的)都写入本地审计日志。AI Agent 需要做"查看服务器现状"这类操作时调用此 skill;它不会写入任何远程状态。
4
4
  license: MIT
5
- compatibility: opencode, claude-code, cursor, windsurf, openhands, codex
5
+ compatibility: opencode, claude-code, cursor, windsurf, openhands, qoder, codex
6
6
  metadata:
7
7
  audience: ai-agents
8
8
  category: ops
package/src/init.js CHANGED
@@ -119,13 +119,13 @@ async function runInteractive({ input = process.stdin, output = process.stdout,
119
119
 
120
120
  let installedSkills = false;
121
121
  if (installSkills) {
122
- const ans = (await rl.question("安装 AI skill 到 opencode / claude / agents 目录? [Y/n]: ")).trim().toLowerCase();
122
+ const ans = (await rl.question("安装 AI skill 到 agents/opencode/claude/qoder? [Y/n]: ")).trim().toLowerCase();
123
123
  const install = ans || "y";
124
124
  if (install !== "n") {
125
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
- }
126
+ const links = results.filter((r) => r.agent !== "agents");
127
+ const fallback = links.some((r) => r.fallback);
128
+ output.write(`rrs skill 已安装: agents (实体), ${links.map((r) => r.agent).join("/")} (软链接)${fallback ? " — Windows 已 fallback 为复制(请开开发者模式启用真软链接)" : ""}\n`);
129
129
  installedSkills = true;
130
130
  }
131
131
  }
package/src/setup.js CHANGED
@@ -4,29 +4,35 @@ const fs = require("fs");
4
4
  const path = require("path");
5
5
  const os = require("os");
6
6
 
7
- const SKILL_NAME = "rrs";
7
+ const SKILL_NAME = "remote-readonly-ssh";
8
+ const CANONICAL = "agents"; // 唯一的物理文件位置;其他目标都是软链接
8
9
 
9
10
  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"],
11
+ opencode: ["opencode"],
12
+ claude: ["claude"],
13
+ claudecode: ["claude"],
14
+ agents: ["agents"],
15
+ openhands: ["agents"],
16
+ qoder: ["qoder"],
17
+ cursor: ["claude"],
18
+ windsurf: ["claude"],
19
+ codex: ["claude"],
17
20
  };
18
21
 
19
22
  const RELATIVE_DIRS = {
20
23
  opencode: [".config", "opencode", "skills"],
21
24
  claude: [".claude", "skills"],
22
25
  agents: [".agents", "skills"],
26
+ qoder: [".qoder", "skills"],
23
27
  };
24
28
 
29
+ const LINK_TARGETS = ["opencode", "claude", "qoder"]; // 除了 agents 之外要建软链接的目录
30
+
25
31
  function listTargets(agent) {
26
32
  const a = (agent || "all").toLowerCase();
27
- if (a === "all") return ["opencode", "claude", "agents"];
33
+ if (a === "all") return [CANONICAL, ...LINK_TARGETS];
28
34
  const groups = ALIAS_TO_GROUP[a];
29
- if (!groups) throw new Error(`未知 --agent [${agent}],支持: all / opencode / claude / cursor / windsurf / codex / openhands / agents`);
35
+ if (!groups) throw new Error(`未知 --agent [${agent}],支持: all / opencode / claude / claudecode / cursor / windsurf / codex / qoder / agents / openhands`);
30
36
  return groups;
31
37
  }
32
38
 
@@ -40,23 +46,80 @@ function skillSrc(packageRoot) {
40
46
  return path.join(packageRoot, "skills", SKILL_NAME, "SKILL.md");
41
47
  }
42
48
 
49
+ // 实体文件必须先存在;其他目录作为软链接指向它
50
+ function ensureCanonical(packageRoot, home) {
51
+ const dest = targetFile(CANONICAL, home);
52
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
53
+ fs.writeFileSync(dest, fs.readFileSync(skillSrc(packageRoot), "utf8"), "utf8");
54
+ return dest;
55
+ }
56
+
43
57
  function install(packageRoot, opts = {}) {
44
58
  const agent = opts.agent || "all";
45
59
  const dryRun = !!opts.dryRun;
46
60
  const home = opts.home || os.homedir();
61
+ // 默认尝试软链接;可在测试中置 false 强制复制(避免 Windows 权限要求)
62
+ const useSymlink = opts.useSymlink !== false;
47
63
  const src = skillSrc(packageRoot);
48
64
  if (!fs.existsSync(src)) throw new Error(`缺少 skill 资源: ${src}`);
49
- const targets = listTargets(agent);
65
+
66
+ const requested = listTargets(agent);
67
+ const wantsLinks = requested.some((t) => t !== CANONICAL);
50
68
  const results = [];
51
- for (const t of targets) {
69
+
70
+ // 物理文件:若请求包含 agents 或任何链接目标,都必须确保 agents 实体存在
71
+ const needCanonical = requested.includes(CANONICAL) || wantsLinks;
72
+ if (needCanonical && !dryRun) {
73
+ ensureCanonical(packageRoot, home);
74
+ }
75
+
76
+ const canonicalPath = targetFile(CANONICAL, home);
77
+
78
+ for (const t of requested) {
52
79
  const dest = targetFile(t, home);
80
+ if (t === CANONICAL) {
81
+ if (dryRun) {
82
+ results.push({ agent: t, dir: dest, action: "would-write", exists: fs.existsSync(dest) });
83
+ } else {
84
+ results.push({ agent: t, dir: dest, action: "written", exists: true });
85
+ }
86
+ continue;
87
+ }
88
+ // 软链接:agents 实体 → 该目录的 SKILL.md
53
89
  if (dryRun) {
54
- results.push({ agent: t, dir: dest, action: "would-write", exists: fs.existsSync(dest) });
90
+ results.push({
91
+ agent: t,
92
+ dir: dest,
93
+ action: fs.existsSync(dest) ? "would-relink" : "would-link",
94
+ target: canonicalPath,
95
+ });
55
96
  continue;
56
97
  }
57
98
  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 });
99
+ // 清理旧文件/链接
100
+ if (fs.existsSync(dest) || fs.lstatSync(dest, { throwIfNoEntry: false })) {
101
+ try { fs.unlinkSync(dest); } catch (_) {}
102
+ }
103
+ if (useSymlink) {
104
+ try {
105
+ fs.symlinkSync(canonicalPath, dest, "file");
106
+ results.push({ agent: t, dir: dest, action: "linked", exists: true, target: canonicalPath });
107
+ continue;
108
+ } catch (e) {
109
+ // Windows 普通用户缺少 SeCreateSymbolicLinkPrivilege;降级为复制
110
+ if (e.code !== "EPERM" && e.code !== "EACCES" && e.code !== "ENOTSUP") throw e;
111
+ // 继续走复制
112
+ }
113
+ }
114
+ fs.copyFileSync(canonicalPath, dest);
115
+ results.push({
116
+ agent: t,
117
+ dir: dest,
118
+ action: "copied",
119
+ exists: true,
120
+ target: canonicalPath,
121
+ fallback: true,
122
+ });
60
123
  }
61
124
  return results;
62
125
  }
@@ -65,24 +128,36 @@ function uninstall(opts = {}) {
65
128
  const agent = opts.agent || "all";
66
129
  const dryRun = !!opts.dryRun;
67
130
  const home = opts.home || os.homedir();
68
- const targets = listTargets(agent);
131
+ const requested = listTargets(agent);
69
132
  const results = [];
70
- for (const t of targets) {
133
+
134
+ for (const t of requested) {
71
135
  const dest = targetFile(t, home);
72
- const dir = path.dirname(dest);
73
- if (!fs.existsSync(dest)) {
136
+ const lstat = fs.lstatSync(dest, { throwIfNoEntry: false });
137
+ if (!lstat) {
74
138
  results.push({ agent: t, dir: dest, action: "missing", exists: false });
75
139
  continue;
76
140
  }
77
141
  if (dryRun) {
78
- results.push({ agent: t, dir: dest, action: "would-delete", exists: true });
142
+ results.push({
143
+ agent: t,
144
+ dir: dest,
145
+ action: lstat.isSymbolicLink() ? "would-unlink" : "would-delete",
146
+ exists: true,
147
+ });
79
148
  continue;
80
149
  }
81
150
  fs.unlinkSync(dest);
82
- try { fs.rmdirSync(dir); } catch (_) {}
83
- results.push({ agent: t, dir: dest, action: "deleted", exists: false });
151
+ const parent = path.dirname(dest);
152
+ try { fs.rmdirSync(parent); } catch (_) {}
153
+ results.push({
154
+ agent: t,
155
+ dir: dest,
156
+ action: lstat.isSymbolicLink() ? "unlinked" : "deleted",
157
+ exists: false,
158
+ });
84
159
  }
85
160
  return results;
86
161
  }
87
162
 
88
- module.exports = { install, uninstall, listTargets, targetFile, skillSrc, ALIAS_TO_GROUP, SKILL_NAME };
163
+ module.exports = { install, uninstall, listTargets, targetFile, skillSrc, ALIAS_TO_GROUP, RELATIVE_DIRS, SKILL_NAME, CANONICAL, LINK_TARGETS };