clash-kit 1.1.2 → 1.1.5

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
@@ -7,15 +7,13 @@
7
7
 
8
8
  目前已兼容 Windows,MacOs,Linux。
9
9
 
10
- ## 截图
11
-
12
- <img width="1920" alt="image" src="https://github.com/user-attachments/assets/1183f778-62b0-4ac7-ab55-b821b66161f0" />
10
+ <img width="604" height="315" alt="image" src="https://github.com/user-attachments/assets/7c97ef51-5a95-4612-aa43-ce66423d7560" />
13
11
 
14
12
  ## 特性
15
13
 
16
- - 🔄 **订阅管理**:支持添加、切换多个订阅源。
14
+ - 🔄 **订阅管理**:支持添加、切换、修改、删除多个订阅源。
17
15
  - 🌐 **节点切换**:交互式选择并切换当前使用的代理节点。
18
- - 🔥 **热重载**:切换订阅后立即生效,无需重启服务。
16
+ - 🔥 **热重载**:切换订阅/开关 TUN 后立即生效,无需重启服务。
19
17
  - ⚡ **节点测速**:支持多线程并发测速,彩色高亮显示延迟结果。
20
18
  - 📊 **状态监控**:实时查看服务运行状态、当前节点及延迟。
21
19
  - 🛠 **自动初始化**:自动处理二进制文件权限问题。
@@ -23,39 +21,63 @@
23
21
  - 💻 **系统代理**:一键开启/关闭 macOS 系统 HTTP 代理。
24
22
  - 🛡 **TUN 模式**:高级网络模式,接管系统所有流量(真 · 全局代理)。
25
23
 
26
- ## 使用
24
+ ## 安装
27
25
 
28
- ### 1. 安装
26
+ - 支持通过 npm 或其它任意包管理器全局安装:
29
27
 
30
28
  ```bash
31
29
  npm install -g clash-kit
32
30
  # 或者
33
31
  pnpm add -g clash-kit
32
+ # 或者
33
+ yarn global add clash-kit
34
+ ```
35
+
36
+ - 也支持通过 Homebrew 安装:
37
+
38
+ ```bash
39
+ # 1. 先通过 brew tap 添加仓库
40
+ brew tap wangrongding/clash-kit https://github.com/wangrongding/clash-kit
41
+ # 2. 安装 clash-kit
42
+ brew install clash-kit
34
43
  ```
35
44
 
36
- ### 2. 初始化
45
+ ## 使用
46
+
47
+ ### 1. 初始化
37
48
 
38
49
  首次安装后,需要先初始化 Clash 内核与权限:
39
50
 
40
51
  ```bash
41
- clash init
42
52
  # 推荐用简化命令(文档后续均以简化命令为例)
43
- ck init
53
+ ck init # 或者 clash init
44
54
  ```
45
55
 
46
- ### 3. 添加订阅
56
+ ### 3. 管理订阅
47
57
 
48
58
  ```bash
49
- # 交互式管理订阅(添加、切换、删除等)【推荐使用这种方式来管理订阅】
59
+ # 交互式管理订阅(添加、切换、修改、删除)【推荐】
50
60
  ck sub
51
61
 
52
62
  # 列出所有订阅
53
63
  ck sub -l
54
64
 
55
- # 手动添加订阅
56
- ck sub -a "https://example.com/subscribe?token=xxx" -n "abcName"
65
+ # 手动添加订阅(-n 名称,-a 链接)
66
+ ck sub -n "test123" -a "https://example.com/subscribe?token=xxx"
67
+
68
+ # 手动切换订阅
69
+ ck sub -u "test123"
57
70
  ```
58
71
 
72
+ 交互式模式 (`ck sub`) 提供以下操作:
73
+
74
+ | 操作 | 说明 |
75
+ | -------- | ------------------------------------------ |
76
+ | 切换订阅 | 从现有订阅中选择并立即生效(热重载) |
77
+ | 添加订阅 | 依次输入名称和链接,完成下载 |
78
+ | 修改订阅 | 重命名 和/或 更换订阅链接(均可留空跳过) |
79
+ | 删除订阅 | 选择后需二次确认,若删除当前订阅会自动解绑 |
80
+
59
81
  ### 4. 启动服务
60
82
 
61
83
  启动 Clash 核心服务(建议在一个单独的终端窗口运行):
@@ -68,35 +90,35 @@ ck on # 或者 ck start
68
90
  ck on -s
69
91
  # 启动并自动开启 TUN 模式(全局代理, 需要 sudo 权限)
70
92
  ck on -t
93
+ ```
71
94
 
72
- # 关闭服务并关闭系统代理
95
+ ### 5. 关闭服务 或 重新启动服务
96
+
97
+ ```bash
98
+ # 关闭服务
73
99
  ck off # 或者 ck stop
74
100
 
75
101
  # 重新启动服务
76
102
  ck rs # 或者 ck restart
77
103
  ```
78
104
 
79
- ### 4. 节点切换(自动测速)
105
+ ### 6. 节点切换(自动测速)
80
106
 
81
107
  进入交互式界面,自动对当前节点组进行并发测速,并展示带有即时延迟数据的节点列表供选择。
82
108
 
83
109
  ```bash
84
110
  # 切换节点 (支持别名: node, proxy, switch)
85
111
  ck use
86
- # 或者
87
- ck switch
88
- # 或者
89
- ck node
90
112
  ```
91
113
 
92
- ### 5. 更多功能
114
+ ### 7. 更多功能
93
115
 
94
116
  ```bash
95
117
  # 查看状态
96
118
  ck info # 或者 ck status, ck view
97
119
 
98
120
  # 节点并发测速 (仅测速不切换,支持别名: test, ls, t)
99
- ck list # 或者 ck test, ck ls
121
+ ck ls # 或者 ck list,ck testck t
100
122
 
101
123
  # 设置系统代理
102
124
  ck sys on
@@ -117,14 +139,18 @@ ck tun off # 关闭
117
139
  | `ck rs` (`restart`) | 重启 Clash 服务 | `ck rs` `ck rs -s` (重启并设置系统代理) `ck rs -t` (重启并开启 TUN 模式) |
118
140
  | `ck info` (`status`, `view`) | 查看运行状态及当前节点延迟 | `ck info` / `ck status` / `ck view` |
119
141
  | `ck sysproxy` (`sys`) | 设置系统代理 | `ck sys on` / `ck sys off` |
120
- | `ck tun` | 设置 TUN 模式 (需要 sudo) | `ck tun on` |
142
+ | `ck tun` | 设置 TUN 模式 (需要 sudo) | `ck tun on` / `ck tun off` |
121
143
  | `ck sub` | 管理订阅(交互式)【推荐】 | `ck sub` |
122
144
  | `ck sub -l` | 列出所有订阅 | `ck sub -l` |
123
- | `ck sub -a <url>` | 添加订阅 | `ck sub -a "http..." -n "pro"` |
145
+ | `ck sub -n <name> -a <url>` | 添加订阅 | `ck sub -n "pro" -a "http..."` |
124
146
  | `ck sub -u <name>` | 切换订阅 | `ck sub -u "pro"` |
125
147
  | `ck use` (`node`, `switch`) | 切换节点 (自动测速) | `ck use` / `ck node` |
126
148
  | `ck list` (`ls`, `test`, `t`) | 节点测速列表 (不切换) | `ck list` / `ck test` |
127
149
 
150
+ ## 截图
151
+
152
+ <img width="1920" alt="image" src="https://github.com/user-attachments/assets/1183f778-62b0-4ac7-ab55-b821b66161f0" />
153
+
128
154
  ## License
129
155
 
130
156
  [MIT](./LICENSE).
package/bin/index.js CHANGED
@@ -12,10 +12,26 @@ import { status } from '../lib/commands/status.js'
12
12
  import { manageSub } from '../lib/commands/sub.js'
13
13
  import { proxy } from '../lib/commands/proxy.js'
14
14
  import { test } from '../lib/commands/test.js'
15
+ import updateNotifier from 'update-notifier'
15
16
 
16
17
  const require = createRequire(import.meta.url)
17
18
  const pkg = require('../package.json')
18
19
 
20
+ updateNotifier({
21
+ pkg: pkg,
22
+ updateCheckInterval: 1000 * 60 * 60 * 24, // 检查更新间隔,1 day
23
+ shouldNotifyInNpmScript: true,
24
+ }).notify({
25
+ isGlobal: true,
26
+ boxenOptions: {
27
+ title: '有新版本的 Clash Kit 可用',
28
+ padding: 1,
29
+ margin: 1,
30
+ align: 'center',
31
+ borderColor: 'yellowBright',
32
+ borderStyle: 'round',
33
+ },
34
+ })
19
35
  const program = new Command()
20
36
 
21
37
  program.name('clash').alias('ck').description('Clash CLI 管理工具 (Alias: ck)').version(pkg.version, '-v, --version')
@@ -71,6 +87,7 @@ program
71
87
  .option('-n, --name <name>', '订阅名称')
72
88
  .option('-l, --list', '列出所有订阅')
73
89
  .option('-u, --use <name>', '切换使用的订阅')
90
+ .option('-d, --delete <name>', '删除订阅')
74
91
  .action(manageSub)
75
92
 
76
93
  // 切换节点
package/lib/api.js CHANGED
@@ -76,7 +76,7 @@ export async function getProxyDelay(proxyName, testUrl = 'http://www.gstatic.com
76
76
  try {
77
77
  const res = await axios.get(`${getApiBase()}/proxies/${encodeURIComponent(proxyName)}/delay`, {
78
78
  params: {
79
- timeout: 5000,
79
+ timeout: 3000,
80
80
  url: testUrl,
81
81
  },
82
82
  headers,
@@ -120,6 +120,12 @@ export async function init(options) {
120
120
  console.log('正在初始化 Clash 内核...')
121
121
  await downloadClash(rootDir)
122
122
  console.log('Clash 内核初始化成功!')
123
+
124
+ console.log(chalk.bold.green('\n✅ 初始化完成!接下来:'))
125
+ console.log(chalk.cyan(' 1. ck sub ') + chalk.gray('添加订阅'))
126
+ console.log(chalk.cyan(' 2. ck on ') + chalk.gray('启动 Clash 服务'))
127
+ console.log(chalk.cyan(' 3. ck sys on ') + chalk.gray('开启系统代理'))
128
+ console.log(chalk.gray('\n 更多帮助: ck help\n'))
123
129
  } catch (err) {
124
130
  console.error(`初始化失败: ${err.message}`)
125
131
  process.exit(1)
@@ -8,8 +8,6 @@ export async function proxy() {
8
8
  try {
9
9
  let proxies = await api.getProxies()
10
10
  spinner.stop()
11
-
12
- // 通常我们只关心 Proxy 组或者 Selector 类型的组
13
11
  const groups = Object.values(proxies).filter(p => p.type === 'Selector')
14
12
 
15
13
  if (groups.length === 0) {
@@ -35,39 +33,49 @@ export async function proxy() {
35
33
 
36
34
  const updatedGroup = proxies[groupName]
37
35
 
38
- // 选择节点
39
- const proxyName = await select({
40
- message: `[${groupName}] 当前: ${updatedGroup.now}, 请选择节点:`,
41
- pageSize: 15,
42
- choices: updatedGroup.all.map(n => {
43
- const node = proxies[n]
44
- const lastHistory = node?.history && node.history.length ? node.history[node.history.length - 1] : null
45
- let delayInfo = ''
36
+ // 构建节点条目并按延迟排序,超时/未测速排最后
37
+ const nodeEntries = updatedGroup.all.map(n => {
38
+ const node = proxies[n]
39
+ const lastHistory = node?.history && node.history.length ? node.history[node.history.length - 1] : null
40
+ let delay = Infinity
41
+ let delayInfo = ''
46
42
 
47
- if (lastHistory && lastHistory.delay > 0) {
48
- const delay = lastHistory.delay
49
- if (delay < 800) {
50
- delayInfo = chalk.green(` ${delay}ms`)
51
- } else if (delay < 1500) {
52
- delayInfo = chalk.yellow(` ${delay}ms`)
53
- } else {
54
- delayInfo = chalk.red(` ${delay}ms`)
55
- }
56
- } else if (lastHistory && lastHistory.delay === 0) {
57
- delayInfo = chalk.red(' [超时]')
43
+ if (lastHistory && lastHistory.delay > 0) {
44
+ delay = lastHistory.delay
45
+ if (delay < 200) {
46
+ delayInfo = chalk.green(` ${delay}ms`)
47
+ } else if (delay < 800) {
48
+ delayInfo = chalk.yellow(` ${delay}ms`)
58
49
  } else {
59
- delayInfo = chalk.gray(' [未测速]')
50
+ delayInfo = chalk.red(` ${delay}ms`)
60
51
  }
52
+ } else if (lastHistory && lastHistory.delay === 0) {
53
+ delayInfo = chalk.red(' [超时]')
54
+ } else {
55
+ delayInfo = chalk.gray(' [未测速]')
56
+ }
61
57
 
62
- return { name: `${n}${delayInfo}`, value: n }
63
- }),
58
+ return { name: `${n}${delayInfo}`, value: n, delay }
59
+ })
60
+
61
+ nodeEntries.sort((a, b) => a.delay - b.delay)
62
+
63
+ // 选择节点
64
+ const proxyName = await select({
65
+ message: `[${groupName}] 当前: ${updatedGroup.now}, 请选择节点:`,
66
+ pageSize: 15,
67
+ choices: nodeEntries.map(({ name, value }) => ({ name, value })),
64
68
  })
65
69
 
66
70
  spinner = ora(`正在切换到 ${proxyName}...`).start()
67
71
  await api.switchProxy(groupName, proxyName)
68
72
  spinner.succeed(`已切换 ${groupName} -> ${proxyName}`)
69
73
  } catch (err) {
70
- if (spinner && spinner.isSpinning) spinner.fail(err.message)
71
- else console.error(err.message)
74
+ if (spinner && spinner.isSpinning) spinner.stop()
75
+ if (err.message && (err.message.includes('ECONNREFUSED') || err.message.includes('无法连接'))) {
76
+ console.error(chalk.red('\nClash 服务未运行,请先执行: ck start\n'))
77
+ } else {
78
+ console.error(chalk.red(err.message))
79
+ }
72
80
  }
73
81
  }
@@ -1,30 +1,38 @@
1
1
  import * as sysproxy from '../sysproxy.js'
2
- import { main as startClashService } from '../../index.js'
2
+ import { main as startClashService } from '../service.js'
3
3
  import { setTun } from './tun.js'
4
+ import ora from 'ora'
5
+ import boxen from 'boxen'
6
+ import chalk from 'chalk'
4
7
 
5
8
  export async function start(options) {
9
+ const spinner = ora('正在启动 Clash 服务...').start()
6
10
  await startClashService()
11
+ spinner.stop()
7
12
 
8
13
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
9
14
 
10
15
  if (options.sysproxy) {
11
- console.log('正在等待 Clash API 就绪以设置系统代理...')
12
-
13
- // 尝试 5 次,每次间隔 1 秒
16
+ const sysSpinner = ora('正在等待 Clash API 就绪以设置系统代理...').start()
17
+ let sysOk = false
14
18
  for (let i = 0; i < 5; i++) {
15
19
  await sleep(1000)
16
20
  try {
17
- const success = await sysproxy.enableSystemProxy()
18
- if (success) break
21
+ const result = await sysproxy.enableSystemProxy()
22
+ if (result.success) {
23
+ sysSpinner.succeed(`系统代理已开启: ${result.host}:${result.port}`)
24
+ sysOk = true
25
+ break
26
+ }
19
27
  } catch (e) {
20
- if (i === 4) console.error('设置系统代理超时,请稍后手动设置: clash sysproxy on')
28
+ // 继续重试
21
29
  }
22
30
  }
31
+ if (!sysOk) sysSpinner.fail('设置系统代理超时,请稍后手动执行: ck sys on')
23
32
  }
24
33
 
25
34
  if (options.tun) {
26
- // 稍微延迟一下,确保服务可能有响应
27
- await sleep(1000)
35
+ await sleep(500)
28
36
  await setTun('on')
29
37
  }
30
38
  }
@@ -25,13 +25,28 @@ export async function status() {
25
25
  const configPath = sub.CONFIG_PATH
26
26
  const logPath = path.resolve(path.dirname(configPath), 'clash.log')
27
27
 
28
+ // 订阅节点数
29
+ let profileNodeCount = ''
30
+ if (currentProfile) {
31
+ try {
32
+ const profilePath = path.join(path.dirname(configPath), 'profiles', `${currentProfile}.yaml`)
33
+ const YAML = (await import('yaml')).default
34
+ const fs = (await import('fs')).default
35
+ if (fs.existsSync(profilePath)) {
36
+ const parsed = YAML.parse(fs.readFileSync(profilePath, 'utf8'))
37
+ const count = parsed?.proxies?.length || 0
38
+ if (count > 0) profileNodeCount = chalk.gray(` (${count} 个节点)`)
39
+ }
40
+ } catch {}
41
+ }
42
+
28
43
  const content = []
29
44
  let statusLine = `状态:${chalk.green('运行中')}`
30
45
  if (processInfo?.pid) {
31
46
  statusLine += ` (PID: ${chalk.yellow(processInfo.pid)})`
32
47
  }
33
48
  content.push(statusLine)
34
- content.push(`当前配置: ${currentProfile || '未知'}`)
49
+ content.push(`当前配置: ${currentProfile || '未知'}${profileNodeCount}`)
35
50
  content.push(`运行模式: ${config.mode}`)
36
51
  content.push(`API 地址: ${chalk.cyan(apiBase)}`)
37
52
  content.push(`HTTP 代理: ${chalk.cyan(`http://127.0.0.1:${config['port'] || '未设置'}`)}`)
@@ -40,14 +55,20 @@ export async function status() {
40
55
  `混合代理: ${config['mixed-port'] ? chalk.cyan(`127.0.0.1:${config['mixed-port']}`) : chalk.gray('未设置')}`,
41
56
  )
42
57
  content.push('')
43
- const sysProxyText = sysProxyStatus.enabled
58
+ const sysProxyText = sysProxyStatus?.enabled
44
59
  ? chalk.green(`已开启 (${sysProxyStatus.server}:${sysProxyStatus.port})`)
45
60
  : chalk.gray('未开启')
46
61
  content.push(`系统代理: ${sysProxyText}`)
47
62
  content.push(`TUN 模式(拦截所有流量): ${tunEnabled ? chalk.green('已开启') : chalk.gray('未开启')}`)
48
- content.push('')
49
- content.push(`日志文件: ${chalk.blueBright.underline(logPath)}`)
63
+ if (tunEnabled && config.dns) {
64
+ const dnsServers = config.dns.nameserver?.slice(0, 2).join(', ') || ''
65
+ const dnsMode = config.dns['enhanced-mode'] || ''
66
+ content.push(
67
+ `DNS 模式: ${chalk.cyan(dnsMode || '默认')}${dnsServers ? ' 服务器: ' + chalk.cyan(dnsServers) : ''}`,
68
+ )
69
+ }
50
70
  content.push(`配置文件: ${chalk.blueBright.underline(configPath)}`)
71
+ content.push(`日志文件: ${chalk.blueBright.underline(logPath)}`)
51
72
  content.push('')
52
73
 
53
74
  content.push('')
@@ -57,7 +78,7 @@ export async function status() {
57
78
 
58
79
  console.log(
59
80
  boxen(content.join('\n'), {
60
- title: chalk.bold.bgGreen('Clash Kit'),
81
+ title: chalk.bold.whiteBright.bgGreen(' Clash Kit '),
61
82
  titleAlignment: 'center',
62
83
  padding: 1,
63
84
  borderStyle: 'bold',
@@ -79,7 +100,7 @@ export async function status() {
79
100
  } else {
80
101
  delayStr = chalk.red('超时/失败')
81
102
  }
82
- console.log(`🚀 节点延迟 [${group.name}: ${group.now}]: ${delayStr}`)
103
+ console.log(`🚀 节点延迟 [${group.name}: ${group.now}]: ${delayStr}\n`)
83
104
  }
84
105
  } catch (err) {
85
106
  if (spinner.isSpinning) spinner.stop()
@@ -91,7 +112,7 @@ export async function status() {
91
112
  content.push(`当前配置文件: ${configPath || '未知'}`)
92
113
  console.log(
93
114
  boxen(content.join('\n'), {
94
- title: chalk.bold.bgYellow('Clash Kit'),
115
+ title: chalk.bold.bgYellow(' Clash Kit '),
95
116
  titleAlignment: 'center',
96
117
  padding: 1,
97
118
  borderStyle: 'single',
@@ -11,18 +11,18 @@ export async function stop() {
11
11
  try {
12
12
  let wasTunEnabled = false
13
13
 
14
- // 1. 关闭系统代理
14
+ // 1. 关闭系统代理(失败不中断)
15
15
  spinner.text = '正在关闭系统代理...'
16
- await sysproxy.disableSystemProxy()
16
+ await sysproxy.disableSystemProxy().catch(() => {})
17
17
 
18
- // 2. 检查并关闭 TUN 模式
19
- const tunEnabled = await tun.isTunEnabled()
18
+ // 2. 检查并关闭 TUN 模式(失败不中断,继续杀进程)
19
+ const tunEnabled = await tun.isTunEnabled().catch(() => false)
20
20
  if (tunEnabled) {
21
21
  wasTunEnabled = true
22
22
  spinner.text = '正在关闭 TUN 模式...'
23
- const result = await setTun('off', { silent: true })
23
+ const result = await setTun('off', { silent: true }).catch(() => ({ success: false }))
24
24
  if (!result.success) {
25
- throw new Error(`关闭 TUN 模式失败: ${result.error}`)
25
+ spinner.text = 'TUN 关闭失败,继续停止进程...'
26
26
  }
27
27
  }
28
28
 
@@ -40,7 +40,7 @@ export async function stop() {
40
40
 
41
41
  console.log(
42
42
  boxen(content.join('\n'), {
43
- title: 'Clash Kit',
43
+ title: ' Clash Kit ',
44
44
  padding: 1,
45
45
  margin: 1,
46
46
  borderStyle: 'round',
@@ -51,7 +51,7 @@ export async function stop() {
51
51
  } else {
52
52
  console.log(
53
53
  boxen(chalk.yellow('未找到运行中的 Clash 服务'), {
54
- title: 'Clash Kit',
54
+ title: ' Clash Kit ',
55
55
  padding: 1,
56
56
  margin: 1,
57
57
  borderStyle: 'round',
@@ -4,7 +4,8 @@ import fs from 'fs'
4
4
  import { fileURLToPath } from 'url'
5
5
  import chalk from 'chalk'
6
6
  import * as sub from '../subscription.js'
7
- import { CLASH_BIN_PATH } from '../../index.js'
7
+ import { confirm } from '@inquirer/prompts'
8
+ import { CLASH_BIN_PATH } from '../service.js'
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url)
10
11
  const __dirname = path.dirname(__filename)
@@ -45,37 +46,79 @@ export async function manageSub(options) {
45
46
  } catch (err) {
46
47
  console.error(err.message)
47
48
  }
49
+ } else if (options.delete) {
50
+ console.log('🌸🌸🌸 / options: ', options)
51
+ try {
52
+ sub.deleteProfile(options.delete)
53
+ console.log(chalk.green(`订阅 "${options.delete}" 已删除`))
54
+ } catch (err) {
55
+ console.error(chalk.red(err.message))
56
+ }
48
57
  } else {
49
58
  // 交互式模式
50
59
  const profiles = sub.listProfiles()
51
60
 
52
- const action = await select({
53
- message: '请选择操作:',
54
- choices: [
55
- { name: '切换订阅', value: 'switch' },
56
- { name: '添加订阅', value: 'add' },
57
- ],
58
- })
61
+ const choices = [
62
+ { name: '切换订阅', value: 'switch' },
63
+ { name: '添加订阅', value: 'add' },
64
+ { name: '修改订阅', value: 'edit' },
65
+ { name: '删除订阅', value: 'delete' },
66
+ ]
67
+
68
+ const action = await select({ message: '请选择操作:', choices })
59
69
 
60
70
  if (action === 'switch') {
61
- if (profiles.length === 0) {
62
- console.log('暂无订阅,请先添加')
63
- return
64
- }
71
+ if (profiles.length === 0) return console.log('暂无订阅,请先添加')
65
72
  const profile = await select({
66
73
  message: '选择要使用的订阅:',
67
74
  choices: profiles.map(p => ({ name: p, value: p })),
68
75
  })
69
76
  await sub.useProfile(profile)
70
77
  } else if (action === 'add') {
71
- const url = await input({ message: '请输入订阅链接:' })
72
78
  const name = await input({ message: '请输入订阅名称:' })
73
-
79
+ const url = await input({ message: '请输入订阅链接:' })
74
80
  try {
75
81
  await handleAddSubscription(url, name)
76
82
  } catch (err) {
77
83
  console.error(err.message)
78
84
  }
85
+ } else if (action === 'edit') {
86
+ if (profiles.length === 0) return console.log('暂无订阅,请先添加')
87
+ const profile = await select({
88
+ message: '选择要修改的订阅:',
89
+ choices: profiles.map(p => ({ name: p, value: p })),
90
+ })
91
+ const newName = await input({ message: `新名称 (留空保持 "${profile}" 不变):` })
92
+ const newUrl = await input({ message: '新订阅链接 (留空则不重新下载):' })
93
+ const finalName = newName.trim() || profile
94
+ try {
95
+ if (newName.trim() && newName.trim() !== profile) {
96
+ sub.renameProfile(profile, finalName)
97
+ console.log(chalk.green(`已重命名: ${profile} → ${finalName}`))
98
+ }
99
+ if (newUrl.trim()) {
100
+ await sub.downloadSubscription(newUrl.trim(), finalName)
101
+ }
102
+ if (!newName.trim() && !newUrl.trim()) {
103
+ console.log(chalk.gray('未做任何修改'))
104
+ }
105
+ } catch (err) {
106
+ console.error(chalk.red(err.message))
107
+ }
108
+ } else if (action === 'delete') {
109
+ if (profiles.length === 0) return console.log('暂无订阅,请先添加')
110
+ const profile = await select({
111
+ message: '选择要删除的订阅:',
112
+ choices: profiles.map(p => ({ name: p, value: p })),
113
+ })
114
+ const ok = await confirm({ message: `确定删除订阅 "${profile}"?`, default: false })
115
+ if (!ok) return console.log(chalk.gray('已取消'))
116
+ try {
117
+ sub.deleteProfile(profile)
118
+ console.log(chalk.green(`订阅 "${profile}" 已删除`))
119
+ } catch (err) {
120
+ console.error(chalk.red(err.message))
121
+ }
79
122
  }
80
123
  }
81
124
  }
@@ -3,7 +3,17 @@ import * as api from '../api.js'
3
3
 
4
4
  export async function test() {
5
5
  try {
6
- const proxies = await api.getProxies()
6
+ let proxies
7
+ try {
8
+ proxies = await api.getProxies()
9
+ } catch (err) {
10
+ if (err.message && (err.message.includes('ECONNREFUSED') || err.message.includes('无法连接'))) {
11
+ console.error(chalk.red('\nClash 服务未运行,请先执行: ck start\n'))
12
+ } else {
13
+ console.error(chalk.red(err.message))
14
+ }
15
+ return
16
+ }
7
17
  // 默认测速 Proxy 组的所有节点
8
18
  const group = proxies['Proxy'] || Object.values(proxies).find(p => p.type === 'Selector')
9
19
 
@@ -15,17 +25,13 @@ export async function test() {
15
25
  console.log(`\n[${group.name}]${group.all.length}个节点, 当前选中: ${group.now}\n`)
16
26
 
17
27
  const results = []
18
- const concurrency = 10 // 并发数量
19
- const queue = [...group.all]
20
28
  const total = group.all.length
21
29
  let completed = 0
22
30
  const current = group.now // 当前选中节点
23
31
 
24
- const worker = async () => {
25
- while (queue.length > 0) {
26
- const name = queue.shift()
27
- if (!name) break
28
-
32
+ // 全并发测速
33
+ await Promise.all(
34
+ group.all.map(async name => {
29
35
  try {
30
36
  const testUrl = name === 'DIRECT' ? 'http://connect.rom.miui.com/generate_204' : undefined
31
37
  const delay = await api.getProxyDelay(name, testUrl)
@@ -46,10 +52,8 @@ export async function test() {
46
52
  completed++
47
53
  results.push({ name, delay: 99999, isCurrent: name === current })
48
54
  }
49
- }
50
- }
51
-
52
- await Promise.all(Array.from({ length: Math.min(concurrency, total) }, () => worker()))
55
+ }),
56
+ )
53
57
 
54
58
  console.log(chalk.bold.blue('\n=== 测速结果 (Top 5) ==='))
55
59
  results.sort((a, b) => a.delay - b.delay)
@@ -4,7 +4,7 @@ import ora from 'ora'
4
4
  import boxen from 'boxen'
5
5
  import * as tun from '../tun.js'
6
6
  import * as sysnet from '../sysnet.js'
7
- import { main as startClashService } from '../../index.js'
7
+ import { main as startClashService } from '../service.js'
8
8
 
9
9
  async function turnOn() {
10
10
  const spinner = ora('正在配置 TUN 模式...').start()
@@ -73,9 +73,9 @@ async function turnOff(options = {}) {
73
73
  await tun.disableTun()
74
74
 
75
75
  if (spinner) spinner.text = '正在恢复系统 DNS...'
76
- const dnsResult = sysnet.setDNS([]) // This is silent now
76
+ const dnsResult = sysnet.setDNS([])
77
77
  if (!dnsResult.success) {
78
- throw new Error(`恢复 DNS 失败: ${dnsResult.error}`)
78
+ console.warn(chalk.yellow(`\n DNS 恢复失败 (可忽略,DNS 将在重启后自动恢复): ${dnsResult.error}`))
79
79
  }
80
80
 
81
81
  if (spinner) spinner.stop()
@@ -1,25 +1,26 @@
1
- import { spawn, execSync } from 'child_process'
1
+ import { spawn } from 'child_process'
2
2
  import path from 'path'
3
3
  import fs from 'fs'
4
4
  import chalk from 'chalk'
5
5
  import axios from 'axios'
6
6
  import ora from 'ora'
7
- import boxen from 'boxen'
8
7
  import YAML from 'yaml'
9
8
  import { fileURLToPath } from 'url'
10
- import { getApiBase, getProxyPort } from './lib/api.js'
11
- import { status } from './lib/commands/status.js'
12
- import * as sysproxy from './lib/sysproxy.js'
13
- import * as tun from './lib/tun.js'
14
- import { isPortOpen, extractPort, getPortOccupier } from './lib/port.js'
15
- import { killClashProcess } from './lib/kernel.js'
9
+ import { getApiBase } from './api.js'
10
+ import { status } from './commands/status.js'
11
+ import * as sysproxy from './sysproxy.js'
12
+ import * as tun from './tun.js'
13
+ import { isPortOpen, extractPort, getPortOccupier } from './port.js'
14
+ import { killClashProcess } from './kernel.js'
16
15
 
17
16
  const __filename = fileURLToPath(import.meta.url)
18
17
  const __dirname = path.dirname(__filename)
19
18
 
20
19
  // ---------------- 配置项 ----------------
21
- export const CLASH_BIN_PATH = path.join(__dirname, process.platform === 'win32' ? 'clash-kit.exe' : 'clash-kit') // 解压后的二进制文件路径
22
- export const CLASH_CONFIG_PATH = path.join(__dirname, 'config.yaml') // 配置文件路径
20
+ export const CLASH_BIN_PATH = path.join(__dirname, '..', process.platform === 'win32' ? 'clash-kit.exe' : 'clash-kit') // 解压后的二进制文件路径
21
+ export const CLASH_CONFIG_PATH = path.join(__dirname, '..', 'config.yaml') // 配置文件路径
22
+
23
+ const ROOT_DIR = path.join(__dirname, '..')
23
24
 
24
25
  async function checkPorts() {
25
26
  try {
@@ -70,11 +71,11 @@ async function startClash() {
70
71
  // 检查端口占用 (核心策略:报错/启动失败)
71
72
  await checkPorts()
72
73
 
73
- const logPath = path.join(__dirname, 'clash.log')
74
+ const logPath = path.join(ROOT_DIR, 'clash.log')
74
75
  const logFd = fs.openSync(logPath, 'a')
75
76
 
76
- const clashProcess = spawn(CLASH_BIN_PATH, ['-f', CLASH_CONFIG_PATH, '-d', __dirname], {
77
- cwd: __dirname,
77
+ const clashProcess = spawn(CLASH_BIN_PATH, ['-f', CLASH_CONFIG_PATH, '-d', ROOT_DIR], {
78
+ cwd: ROOT_DIR,
78
79
  detached: true,
79
80
  stdio: ['ignore', logFd, logFd], // 重定向 stdout 和 stderr 到日志文件
80
81
  })
@@ -177,7 +178,7 @@ export async function main() {
177
178
 
178
179
  if (!started) {
179
180
  spinner.fail(chalk.red('启动失败'))
180
- const logPath = path.join(__dirname, 'clash.log')
181
+ const logPath = path.join(ROOT_DIR, 'clash.log')
181
182
  if (fs.existsSync(logPath)) {
182
183
  console.log(chalk.yellow('\n------- clash.log (Last 20 lines) -------'))
183
184
  const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n')
@@ -197,8 +198,3 @@ export async function main() {
197
198
  // 调用 status 命令来打印完整的状态信息
198
199
  await status()
199
200
  }
200
-
201
- // 运行脚本
202
- if (process.argv[1] === __filename) {
203
- main()
204
- }
@@ -126,3 +126,27 @@ export async function useProfile(name) {
126
126
  spinner.succeed('配置已切换(Clash 未运行,将在下次启动时生效)')
127
127
  }
128
128
  }
129
+
130
+ export function deleteProfile(name) {
131
+ const filePath = path.join(PROFILES_DIR, `${name}.yaml`)
132
+ if (!fs.existsSync(filePath)) throw new Error(`订阅 ${name} 不存在`)
133
+ fs.unlinkSync(filePath)
134
+ // 若删除的是当前使用的订阅,清除记录
135
+ if (fs.existsSync(CURRENT_PROFILE_PATH)) {
136
+ const current = fs.readFileSync(CURRENT_PROFILE_PATH, 'utf8').trim()
137
+ if (current === name) fs.unlinkSync(CURRENT_PROFILE_PATH)
138
+ }
139
+ }
140
+
141
+ export function renameProfile(oldName, newName) {
142
+ const oldPath = path.join(PROFILES_DIR, `${oldName}.yaml`)
143
+ const newPath = path.join(PROFILES_DIR, `${newName}.yaml`)
144
+ if (!fs.existsSync(oldPath)) throw new Error(`订阅 ${oldName} 不存在`)
145
+ if (fs.existsSync(newPath)) throw new Error(`订阅名称 ${newName} 已存在`)
146
+ fs.renameSync(oldPath, newPath)
147
+ // 同步更新当前使用记录
148
+ if (fs.existsSync(CURRENT_PROFILE_PATH)) {
149
+ const current = fs.readFileSync(CURRENT_PROFILE_PATH, 'utf8').trim()
150
+ if (current === oldName) fs.writeFileSync(CURRENT_PROFILE_PATH, newName)
151
+ }
152
+ }
package/lib/sysnet.js CHANGED
@@ -1,11 +1,4 @@
1
1
  import { execSync } from 'child_process'
2
- import fs from 'fs'
3
- import path from 'path'
4
- import { fileURLToPath } from 'url'
5
-
6
- const __filename = fileURLToPath(import.meta.url)
7
- const __dirname = path.dirname(__filename)
8
- const HELPER_BIN = path.join(__dirname, '../bin/dns-helper')
9
2
 
10
3
  /**
11
4
  * 获取 macOS 当前主要的网络服务名称 (Wi-Fi, Ethernet 等)
@@ -38,34 +31,18 @@ function getMainNetworkService() {
38
31
  */
39
32
  export function setDNS(servers) {
40
33
  if (process.platform !== 'darwin') {
41
- return { success: true, message: 'Platform is not macOS, skipping DNS change.' }
34
+ return { success: true }
42
35
  }
43
36
 
44
37
  const service = getMainNetworkService()
45
38
  if (!service) {
46
- return { success: false, error: 'Could not determine main network service.' }
47
- }
48
-
49
- // 检查是否存在 helper
50
- let useHelper = false
51
- try {
52
- if (fs.existsSync(HELPER_BIN)) {
53
- const stats = fs.statSync(HELPER_BIN)
54
- if (stats.uid === 0 && (stats.mode & 0o4000)) {
55
- useHelper = true
56
- }
57
- }
58
- } catch (e) {
59
- /* ignore */
39
+ return { success: false, error: '无法获取网络服务' }
60
40
  }
61
41
 
62
- const serversArg = servers.length > 0 ? servers.join(' ') : 'Empty'
63
- const command = useHelper
64
- ? `"${HELPER_BIN}" "${service}" ${serversArg}`
65
- : `sudo networksetup -setdnsservers "${service}" ${serversArg}`
42
+ const serversArg = servers.length > 0 ? servers.join(' ') : '"Empty"'
66
43
 
67
44
  try {
68
- execSync(command, { stdio: 'pipe' }) // Use 'pipe' to suppress output
45
+ execSync(`sudo networksetup -setdnsservers "${service}" ${serversArg}`)
69
46
  return { success: true }
70
47
  } catch (e) {
71
48
  return { success: false, error: e.message }
package/lib/tun.js CHANGED
@@ -9,8 +9,6 @@ const __filename = fileURLToPath(import.meta.url)
9
9
  const __dirname = path.dirname(__filename)
10
10
  const CONFIG_PATH = path.join(__dirname, '../config.yaml')
11
11
  const BIN_PATH = path.join(__dirname, '../clash-kit')
12
- const HELPER_SRC = path.join(__dirname, '../scripts/dns-helper.c')
13
- const HELPER_BIN = path.join(__dirname, '../bin/dns-helper')
14
12
 
15
13
  export function checkTunPermissions() {
16
14
  if (process.platform === 'win32') return true // Windows 需要管理员权限终端,难以通过文件属性判断
@@ -21,15 +19,7 @@ export function checkTunPermissions() {
21
19
  // 检查所有者是否为 root (uid 0) 并且拥有 SUID 位
22
20
  const isRootOwned = stats.uid === 0
23
21
  const hasSuid = (stats.mode & 0o4000) === 0o4000
24
-
25
- // 同时也检查 DNS 辅助工具的权限
26
- let helperOk = false
27
- if (fs.existsSync(HELPER_BIN)) {
28
- const hStats = fs.statSync(HELPER_BIN)
29
- helperOk = hStats.uid === 0 && (hStats.mode & 0o4000) === 0o4000
30
- }
31
-
32
- return isRootOwned && hasSuid && helperOk
22
+ return isRootOwned && hasSuid
33
23
  } catch (e) {
34
24
  return false
35
25
  }
@@ -48,20 +38,6 @@ export function setupPermissions() {
48
38
  console.log('正在提升内核权限 (需要 sudo 密码)...')
49
39
  execSync(`sudo ${cmdChown}`, { stdio: 'inherit' })
50
40
  execSync(`sudo ${cmdChmod}`, { stdio: 'inherit' })
51
-
52
- // 编译并设置 DNS 辅助工具
53
- if (fs.existsSync(HELPER_SRC)) {
54
- console.log('正在编译 DNS 辅助工具...')
55
- try {
56
- execSync(`cc "${HELPER_SRC}" -o "${HELPER_BIN}"`, { stdio: 'inherit' })
57
- execSync(`sudo chown root:${group} "${HELPER_BIN}"`, { stdio: 'inherit' })
58
- execSync(`sudo chmod +sx "${HELPER_BIN}"`, { stdio: 'inherit' })
59
- console.log('DNS 辅助工具设置成功')
60
- } catch (compileErr) {
61
- console.warn('DNS 辅助工具编译或设置失败,系统 DNS 切换可能仍需密码:', compileErr.message)
62
- }
63
- }
64
-
65
41
  return true
66
42
  } catch (e) {
67
43
  throw new Error('权限设置失败')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clash-kit",
3
- "version": "1.1.2",
3
+ "version": "1.1.5",
4
4
  "type": "module",
5
5
  "description": "A command-line interface for managing Clash configurations, subscriptions, and proxies.",
6
6
  "bin": {
@@ -13,12 +13,10 @@
13
13
  ".gitignore",
14
14
  "default.yaml",
15
15
  "country.mmdb",
16
- "index.js",
17
16
  "LICENSE.md",
18
17
  "package.json",
19
18
  "README.md"
20
19
  ],
21
- "main": "index.js",
22
20
  "scripts": {
23
21
  "start": "node bin/index.js start",
24
22
  "test": "echo \"Error: no test specified\" && exit 1",
@@ -45,6 +43,7 @@
45
43
  "commander": "^14.0.2",
46
44
  "inquirer": "^13.1.0",
47
45
  "ora": "^9.0.0",
46
+ "update-notifier": "^7.3.1",
48
47
  "yaml": "^2.8.2"
49
48
  }
50
49
  }
package/bin/dns-helper DELETED
Binary file