@znmin/claude-remote-cli 0.2.0 → 0.2.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 CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## 安装
8
8
 
9
- 要求:Node.js **20.11 或更高版本**、npm 和 OpenSSH 客户端。本地模式需要 `ssh`,服务器同步模式另外需要 `scp` 和系统 `tar`。支持 macOS/Linux 的 x64、arm64;Windows 请在 WSL 内使用。本地无需 Python、Homebrew 或 Anthropic 的 Claude Code。
9
+ 要求:Node.js **20.11 或更高版本**、npm 和 OpenSSH 客户端。本地模式需要 `ssh`,自动配置免密登录需要 `ssh-keygen`,服务器同步模式另外需要 `scp` 和系统 `tar`。支持 macOS/Linux 的 x64、arm64;Windows 请在 WSL 内使用。本地无需 Python、Homebrew 或 Anthropic 的 Claude Code。
10
10
 
11
11
  npm 包名为 `@znmin/claude-remote-cli`,安装后提供 `claude` 和 `claude-remote` 命令。
12
12
 
@@ -25,10 +25,10 @@ npm install -g git+https://github.com/znmin/claude-remote-cli.git
25
25
  也可以从已下载的分发包安装:
26
26
 
27
27
  ```bash
28
- npm install -g ./znmin-claude-remote-cli-0.2.0.tgz
28
+ npm install -g ./znmin-claude-remote-cli-0.2.2.tgz
29
29
  ```
30
30
 
31
- 若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.2.0.tgz`,并确保 `~/.local/bin` 在 PATH 中;卸载时也使用相同的 `--prefix`。
31
+ 若系统 npm 全局目录不可写,可使用 `npm install -g --prefix "$HOME/.local" ./znmin-claude-remote-cli-0.2.2.tgz`,并确保 `~/.local/bin` 在 PATH 中;卸载时也使用相同的 `--prefix`。
32
32
 
33
33
  npm 中不带作用域的 `claude-remote-cli` 属于其他项目。本项目使用独立的 `@znmin/claude-remote-cli` 作用域包名;请安装完整包名,或使用上述 GitHub 地址及本项目分发的 `.tgz` 文件。
34
34
 
@@ -67,6 +67,22 @@ claude --continue
67
67
 
68
68
  未配置服务器时,也可以直接运行 `claude`,按终端提示输入服务器地址和端口。请勿把密码或私钥填入配置;登录使用 OpenSSH 原有认证流程和主机身份验证。
69
69
 
70
+ ### 自动配置 SSH 免密登录(0.2.1)
71
+
72
+ 两种执行模式每次启动都会先检查独立的公钥登录。如果已有可用的 SSH 密钥或 SSH Agent 授权,直接连接,不生成新密钥。
73
+
74
+ 尚不能免密登录时,交互终端会自动引导首次 SSH 登录:确认服务器指纹,按系统 SSH 提示输入服务器密码(或已有私钥口令)。登录成功后,为该地址和端口创建专用 Ed25519 无口令密钥,私钥保存于本机 `~/.ssh/claude-remote/`,目录权限 0700,私钥权限 0600。只把公钥追加到服务器的 `~/.ssh/authorized_keys`,保留其他已有授权;程序不读取或保存服务器密码,也不会关闭主机指纹验证。
75
+
76
+ 独立密钥登录验证成功后,在 `~/.ssh/config` 末尾添加对应 SSH 主机名或别名的 `IdentityFile` 配置,保留原有用户、端口、密钥和跳板机设置,让系统 SSH、SCP 和 Mutagen 都能使用。SSH 的 Host 规则按主机名匹配,因此同一主机其他用户或端口的连接也可能尝试该密钥,是否接受仍由各自服务器账号的授权决定。完成后再次检查默认连接,后续启动无需重复授权。
77
+
78
+ 也可以在配置服务器后单独运行:
79
+
80
+ ```bash
81
+ claude-remote setup-ssh
82
+ ```
83
+
84
+ 非交互运行且免密连接不可用时会停止并提示先在终端完成设置,不自动修改密钥。服务器必须允许公钥登录,并允许当前账号修改默认 `authorized_keys` 文件;强制额外验证码或采用自定义授权路径的服务器需要管理员配置,程序不会修改 sshd 策略。已有带口令密钥如需免提示,可预先交给本机 SSH Agent 管理。
85
+
70
86
  若需要使用特定的 Claude 可执行文件,可指定绝对路径;自定义路径不存在时会提示修正配置,不会自动改用其他程序:
71
87
 
72
88
  ```bash
@@ -116,7 +132,7 @@ Linux 上仍需安装 Playwright 所需的系统共享库,有窗口模式还
116
132
 
117
133
  本地模式要求服务器允许 SSH TCP 转发,Claude Code 版本至少 **2.1.248**;已有版本过旧时会提示在服务器运行 `claude update`。每个会话创建仅监听本机回环地址的工具服务、随机令牌和 SSH 反向隧道;令牌通过 SSH 标准输入写入服务器 0600 临时配置,不出现在命令行参数或同步项目中。退出时清理配置、连接和浏览器。
118
134
 
119
- 启动时使用 `--restricted`、`--tools ""`、`--strict-mcp-config`,限定工具和设置来源、关闭普通 hooks 和自定义命令。保留服务器上的订阅认证,不使用跳过 OAuth 的 `--bare`。本地连接失效时不会退回服务器工具。支持 `--continue`、`--resume`、`--model`、`--effort`、`-p` 和输入/输出格式;暂不接受替换工具配置、加载插件、切换云会话等参数。
135
+ 启动时使用 `--restricted`、`--tools ""`、`--strict-mcp-config`,限定工具和设置来源、关闭普通 hooks。0.2.2 起恢复 `/config`、`/model`、`/help` 等内置交互命令,不再使用禁用整个命令菜单的 `--disable-slash-commands`;通过 `disableSkillShellExecution` 和 `disableBundledSkills` 单独关闭技能内联 shell 和内置技能工作流。恢复菜单不代表所有依赖服务器项目目录或原生工具的命令都适用于本地模式。设置面板修改的是服务器上的 Claude 配置;受限模式仍只加载托管配置和本次 `--settings`,不会恢复读取普通用户/项目 settings.json,因此偏好设置的跨会话持久化仍受该限制影响,可用 `claude --model sonnet` 明确指定启动模型。保留服务器上的订阅认证,不使用跳过 OAuth 的 `--bare`。本地连接失效时不会退回服务器工具。支持 `--continue`、`--resume`、`--model`、`--effort`、`-p` 和输入/输出格式;暂不接受替换工具配置、加载插件、切换云会话等参数。
120
136
 
121
137
  这是工具路由约束,不是对 Claude Code 主进程的系统隔离。认证、会话和日志仍在服务器,管理员管理策略继续生效。文件内容、命令结果和截图会经过服务器交给模型,不整体同步不代表数据不离开本机。
122
138
 
@@ -184,7 +200,7 @@ npm test
184
200
  CLAUDE_REMOTE_INTEGRATION=1 CLAUDE_REMOTE_BROWSER_TEST=1 npm test
185
201
 
186
202
  npm pack
187
- npm install -g ./znmin-claude-remote-cli-0.2.0.tgz
203
+ npm install -g ./znmin-claude-remote-cli-0.2.2.tgz
188
204
 
189
205
  # 确定包名、版本和使用条款后,使用有发布权限的 npm 账号发布
190
206
  npm login
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@znmin/claude-remote-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Run Claude Code on an SSH server with local filesystem, terminal and browser tools, or remote bidirectional sync",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/cli.js CHANGED
@@ -20,6 +20,7 @@ const HELP = `${PACKAGE.name} ${PACKAGE.version}(非 Anthropic 官方客户端
20
20
  claude-remote flush 等待当前目录同步完成
21
21
  claude-remote stop [--all | --name 名称]
22
22
  claude-remote doctor 检查本地环境,不连接服务器
23
+ claude-remote setup-ssh 配置并验证服务器 SSH 免密登录(启动时自动检查)
23
24
  claude-remote install-sync 准备同步工具(通常自动完成)
24
25
  claude-remote install-browser 准备本地受控浏览器(本地模式自动完成)
25
26
  claude-remote run [Claude 参数] 启动远程 Claude
@@ -28,7 +29,7 @@ configure 可选:--mode local|remote、--headless/--headed、--root 远程根
28
29
  新配置默认使用本地执行;旧配置保持服务器模式,直到显式切换。
29
30
  服务器缺少默认 Claude Code 时会自动安装;首次启动需完成认证。
30
31
  此工具不提供服务器或 Claude 账号。
31
- 退出 Claude 后会继续后台同步;停止请运行 claude-remote stop。
32
+ 仅服务器同步模式会在退出后继续后台同步;停止请运行 claude-remote stop。
32
33
  `;
33
34
 
34
35
  async function configure(client, args) {
@@ -97,6 +98,9 @@ export async function main(asClaude, args = process.argv.slice(2), client = new
97
98
  await client.stop(values); return 0;
98
99
  }
99
100
  if (rest.length) throw new Error(`${action} 不接受额外参数。`);
101
+ if (action === 'setup-ssh') {
102
+ await client.ensureSSH(client, await client.settings()); return 0;
103
+ }
100
104
  if (action === 'status') {
101
105
  await client.sync(['list', '--long', '--label-selector', 'claude-remote=true'], { capture: false }); return 0;
102
106
  }
package/src/client.js CHANGED
@@ -6,6 +6,7 @@ import { execute, exists, quote, readJSON, shellJoin, which, withLock, writeJSON
6
6
  import { ensureMutagen } from './installer.js';
7
7
  import { ensureRemoteClaude, remoteEnvironment as prefix } from './remote-claude.js';
8
8
  import { sshArgs } from './ssh.js';
9
+ import { ensureSSH } from './ssh-auth.js';
9
10
  export { sshArgs } from './ssh.js';
10
11
 
11
12
  export const DEFAULTS = {
@@ -58,6 +59,7 @@ export class Client {
58
59
  this.registryFile = path.join(this.directory, 'projects.json');
59
60
  this.lockFile = path.join(this.directory, 'npm-state');
60
61
  this.execute = options.execute || execute;
62
+ this.ensureSSH = options.ensureSSH || ensureSSH;
61
63
  this.ensureMutagen = options.ensureMutagen || ensureMutagen;
62
64
  this.log = options.log || console.error;
63
65
  this.warn = options.warn || console.error;
@@ -148,6 +150,7 @@ export class Client {
148
150
  }
149
151
 
150
152
  async launch(config, extra) {
153
+ await this.ensureSSH(this, config);
151
154
  if (config.mode === 'local') {
152
155
  const { launchLocal } = await import('./local-mode.js');
153
156
  return launchLocal(this, config, extra);
package/src/local-mode.js CHANGED
@@ -39,7 +39,10 @@ export function validateLocalArguments(args) {
39
39
  export function localClaudeArguments(configFile, root, extra) {
40
40
  validateLocalArguments(extra);
41
41
  return ['--restricted', '--tools', '', '--strict-mcp-config', '--mcp-config', configFile,
42
- '--setting-sources', '', '--disable-slash-commands', '--settings', JSON.stringify({ disableAllHooks: true, enabledPlugins: {} }),
42
+ // Keep built-in UI commands (/config, /model, /help) available. Disable
43
+ // skill shell expansion separately instead of disabling the entire menu.
44
+ '--setting-sources', '', '--settings', JSON.stringify({ disableAllHooks: true,
45
+ disableSkillShellExecution: true, disableBundledSkills: true, enabledPlugins: {} }),
43
46
  '--append-system-prompt', `All project operations run on the developer's LOCAL computer through the local MCP server. ` +
44
47
  `The LOCAL project root is ${JSON.stringify(root)}. The server working directory is only for session metadata, not project files. ` +
45
48
  'Call workspace_info before working. Use only local tools for file access, shell commands and browser operations. ' +
@@ -0,0 +1,83 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { createHash } from 'node:crypto';
5
+ import { sshBase } from './ssh.js';
6
+ import { exists, quote, which, withLock } from './system.js';
7
+
8
+ // Force a new public-key connection: an existing multiplexed/password session
9
+ // must never be mistaken for successful key authentication.
10
+ const probeOptions = ['-o', 'BatchMode=yes', '-o', 'PreferredAuthentications=publickey',
11
+ '-o', 'ControlPath=none', '-o', 'ControlMaster=no'];
12
+
13
+ export function authorizeKeyScript(publicKey) {
14
+ if (!/^ssh-ed25519 [A-Za-z0-9+/]+={0,3}(?: [^\r\n]*)?$/.test(publicKey))
15
+ throw new Error('专用 SSH 公钥格式无效。');
16
+ return 'set -eu; umask 077; mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"; ' +
17
+ 'touch "$HOME/.ssh/authorized_keys"; chmod 600 "$HOME/.ssh/authorized_keys"; ' +
18
+ 'key=' + quote(publicKey) + '; ' +
19
+ 'if ! grep -qxF -- "$key" "$HOME/.ssh/authorized_keys"; then ' +
20
+ // A pre-existing file need not end with a newline.
21
+ 'printf "\\n%s\\n" "$key" >> "$HOME/.ssh/authorized_keys"; fi';
22
+ }
23
+
24
+ export async function ensureSSH(client, config) {
25
+ if (!await which('ssh', client.env.PATH)) throw new Error('找不到系统 SSH 客户端。');
26
+ const run = (args, options = {}) => client.execute('ssh', [...sshBase(config), ...args],
27
+ { env: client.env, timeout: 30000, ...options });
28
+ const probe = async (identity = []) => (await run([...probeOptions, ...identity, config.host, 'true'],
29
+ { capture: true, check: false })).code === 0;
30
+ if (await probe()) return;
31
+ if (!client.tty) throw new Error('SSH 免密连接尚不可用。请在交互终端运行 claude-remote setup-ssh,完成首次登录及密钥授权;也请检查服务器地址和主机指纹。');
32
+ if (!await which('ssh-keygen', client.env.PATH)) throw new Error('找不到 ssh-keygen,请安装 OpenSSH 客户端。');
33
+
34
+ const directory = path.join(client.home, '.ssh', 'claude-remote');
35
+ await withLock(path.join(directory, 'setup'), async () => {
36
+ if (await probe()) return;
37
+ const base = os.tmpdir().length > 55 ? '/tmp' : os.tmpdir();
38
+ const temporary = await fs.mkdtemp(path.join(base, 'ccra-'));
39
+ const socket = path.join(temporary, 'ssh');
40
+ try {
41
+ client.log('正在配置 SSH 免密登录。首次连接请确认服务器指纹,并按 SSH 提示输入服务器密码。');
42
+ // Authenticate once; subsequent installation reuses this private socket.
43
+ // Do not capture authentication output or handle passwords in JavaScript.
44
+ await run(['-M', '-S', socket, '-o', 'ControlPersist=no', '-o', 'ControlMaster=yes',
45
+ '-f', '-N', config.host], { timeout: 180000, capture: false });
46
+ const id = createHash('sha256').update(config.host + '\0' + (config.port ?? '')).digest('hex').slice(0, 20);
47
+ const key = path.join(directory, 'id_ed25519_' + id);
48
+ await fs.chmod(directory, 0o700);
49
+ if (!await exists(key)) {
50
+ if (await exists(key + '.pub')) throw new Error('专用 SSH 私钥缺失但公钥仍存在,请检查:' + key);
51
+ client.log('创建仅供该服务器使用的无口令 SSH 密钥,私钥保存在本机,文件权限设为仅本人可读。');
52
+ await client.execute('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'claude-remote-cli', '-f', key],
53
+ { env: client.env, capture: true, timeout: 30000 });
54
+ }
55
+ await fs.chmod(key, 0o600);
56
+ // Derive from the private key so an outdated .pub file cannot authorize
57
+ // the wrong key. Never transmit the private key.
58
+ const derived = await client.execute('ssh-keygen', ['-y', '-P', '', '-f', key],
59
+ { env: client.env, capture: true, timeout: 30000 });
60
+ const publicKey = derived.stdout.trim();
61
+ const script = authorizeKeyScript(publicKey);
62
+ await run(['-S', socket, config.host, 'sh -c ' + quote(script)], { capture: false });
63
+ if (!await probe(['-i', key, '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none']))
64
+ throw new Error('公钥已添加,但独立密钥登录验证失败。请检查服务器的公钥登录策略和 authorized_keys 设置;未修改本机 SSH 配置。');
65
+
66
+ // SSH's ordinary config also makes the key available to Mutagen and scp.
67
+ // IdentityFile is additive; do not replace existing host/user/proxy rules.
68
+ const configFile = path.join(client.home, '.ssh', 'config');
69
+ const host = config.host.split('@').at(-1);
70
+ const escaped = key.replaceAll('%', '%%').replaceAll('\\', '\\\\').replaceAll('"', '\\"');
71
+ const block = `\n# claude-remote-cli ${id}\nHost ${host}\n IdentityFile "${escaped}"\nHost *\n`;
72
+ const previous = await fs.readFile(configFile, 'utf8').catch(error => {
73
+ if (error.code === 'ENOENT') return ''; throw error;
74
+ });
75
+ if (!previous.includes(block)) await fs.appendFile(configFile, block, { mode: 0o600 });
76
+ if (!await probe()) throw new Error('密钥已授权,但默认 SSH 配置验证失败。请检查 ~/.ssh/config 的 IdentityFile 和 IdentitiesOnly 设置。');
77
+ client.log('SSH 免密登录已配置并验证成功,后续连接无需输入服务器密码。');
78
+ } finally {
79
+ await run(['-S', socket, '-O', 'exit', config.host], { capture: true, check: false, timeout: 5000 }).catch(() => {});
80
+ await fs.rm(temporary, { recursive: true, force: true });
81
+ }
82
+ });
83
+ }