@meanc/otter 0.0.1 → 0.0.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 +5 -0
- package/bun.lock +9 -0
- package/index.ts +28 -1
- package/package.json +2 -1
- package/src/commands/core.tsx +71 -35
- package/src/commands/menu.tsx +68 -0
package/README.md
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
Otter (ot) 是一个基于 [Mihomo](https://github.com/MetaCubeX/mihomo)
|
|
4
4
|
核心的极简主义 Clash TUI
|
|
5
5
|
客户端。它专为速度和可组合性而设计,提供流畅的命令行体验和交互式界面。
|
|
6
|
+
<img width="1036" height="532" alt="image" src="https://github.com/user-attachments/assets/3d2969ed-41ee-4bd3-b37a-97173bea2d50" />
|
|
7
|
+
|
|
8
|
+
<img width="658" height="426" alt="image" src="https://github.com/user-attachments/assets/e82dfb1a-3548-45b4-be99-6f4a3bf1ea43" />
|
|
9
|
+
|
|
10
|
+
|
|
6
11
|
|
|
7
12
|
## ✨ 特性
|
|
8
13
|
|
package/bun.lock
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"chalk": "^5.6.2",
|
|
11
11
|
"fs-extra": "^11.3.3",
|
|
12
12
|
"ink": "^6.5.1",
|
|
13
|
+
"ink-select-input": "^6.2.0",
|
|
13
14
|
"js-yaml": "^4.1.1",
|
|
14
15
|
"ps-list": "^9.0.0",
|
|
15
16
|
"react": "^19.2.3",
|
|
@@ -77,6 +78,8 @@
|
|
|
77
78
|
|
|
78
79
|
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
|
|
79
80
|
|
|
81
|
+
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
|
82
|
+
|
|
80
83
|
"fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="],
|
|
81
84
|
|
|
82
85
|
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
|
@@ -87,10 +90,14 @@
|
|
|
87
90
|
|
|
88
91
|
"ink": ["ink@6.5.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.2.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^8.1.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": "^6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-wF3j/DmkM8q5E+OtfdQhCRw8/0ahkc8CUTgEddxZzpEWPslu7YPL3t64MWRoI9m6upVGpfAg4ms2BBvxCdKRLQ=="],
|
|
89
92
|
|
|
93
|
+
"ink-select-input": ["ink-select-input@6.2.0", "", { "dependencies": { "figures": "^6.1.0", "to-rotated": "^1.0.0" }, "peerDependencies": { "ink": ">=5.0.0", "react": ">=18.0.0" } }, "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ=="],
|
|
94
|
+
|
|
90
95
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
|
91
96
|
|
|
92
97
|
"is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="],
|
|
93
98
|
|
|
99
|
+
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
|
100
|
+
|
|
94
101
|
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
|
95
102
|
|
|
96
103
|
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
|
@@ -121,6 +128,8 @@
|
|
|
121
128
|
|
|
122
129
|
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
|
123
130
|
|
|
131
|
+
"to-rotated": ["to-rotated@1.0.0", "", {}, "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q=="],
|
|
132
|
+
|
|
124
133
|
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
|
125
134
|
|
|
126
135
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
package/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import * as proxy from './src/commands/proxy';
|
|
|
6
6
|
import * as system from './src/commands/system';
|
|
7
7
|
import * as ui from './src/commands/ui';
|
|
8
8
|
import * as subscribe from './src/commands/subscribe';
|
|
9
|
+
import * as menu from './src/commands/menu';
|
|
9
10
|
import { BIN_PATH } from './src/utils/paths';
|
|
10
11
|
|
|
11
12
|
const cli = cac('ot');
|
|
@@ -138,4 +139,30 @@ cli.help((sections) => {
|
|
|
138
139
|
return newSections;
|
|
139
140
|
});
|
|
140
141
|
|
|
141
|
-
|
|
142
|
+
const run = async () => {
|
|
143
|
+
// If no args provided, show menu
|
|
144
|
+
if (process.argv.length === 2) {
|
|
145
|
+
const action = await menu.show();
|
|
146
|
+
switch (action) {
|
|
147
|
+
case 'start':
|
|
148
|
+
await core.start();
|
|
149
|
+
break;
|
|
150
|
+
case 'stop':
|
|
151
|
+
await core.stop();
|
|
152
|
+
break;
|
|
153
|
+
case 'status':
|
|
154
|
+
await core.status();
|
|
155
|
+
break;
|
|
156
|
+
case 'ui':
|
|
157
|
+
await ui.ui();
|
|
158
|
+
break;
|
|
159
|
+
case 'quit':
|
|
160
|
+
process.exit(0);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
cli.parse();
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
run();
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
},
|
|
6
6
|
"description": "以水獭为名的clash tui",
|
|
7
7
|
"module": "index.ts",
|
|
8
|
-
"version": "0.0.
|
|
8
|
+
"version": "0.0.2",
|
|
9
9
|
"bin": {
|
|
10
10
|
"ot": "index.ts"
|
|
11
11
|
},
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"chalk": "^5.6.2",
|
|
29
29
|
"fs-extra": "^11.3.3",
|
|
30
30
|
"ink": "^6.5.1",
|
|
31
|
+
"ink-select-input": "^6.2.0",
|
|
31
32
|
"js-yaml": "^4.1.1",
|
|
32
33
|
"ps-list": "^9.0.0",
|
|
33
34
|
"react": "^19.2.3",
|
package/src/commands/core.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { SubscriptionManager } from '../utils/subscription';
|
|
|
6
6
|
import { LOG_FILE } from '../utils/paths';
|
|
7
7
|
import { spawn } from 'child_process';
|
|
8
8
|
import fs from 'fs-extra';
|
|
9
|
+
import * as system from './system';
|
|
9
10
|
|
|
10
11
|
const formatSpeed = (bytes: number) => {
|
|
11
12
|
if (bytes === 0) return '0 B/s';
|
|
@@ -23,9 +24,31 @@ const formatSize = (bytes: number) => {
|
|
|
23
24
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
24
25
|
};
|
|
25
26
|
|
|
27
|
+
const ProgressBar = ({ percent = 0, width = 20, color = 'green' }: { percent?: number, width?: number, color?: string }) => {
|
|
28
|
+
const safePercent = Math.min(1, Math.max(0, percent));
|
|
29
|
+
const completed = Math.floor(safePercent * width);
|
|
30
|
+
const remaining = width - completed;
|
|
31
|
+
return (
|
|
32
|
+
<Text>
|
|
33
|
+
<Text color={color}>{'█'.repeat(completed)}</Text>
|
|
34
|
+
<Text color="gray">{'░'.repeat(remaining)}</Text>
|
|
35
|
+
</Text>
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getSpeedPercent = (bytes: number) => {
|
|
40
|
+
if (bytes <= 0) return 0;
|
|
41
|
+
const MAX_SPEED = 10 * 1024 * 1024; // 10MB/s
|
|
42
|
+
// Linear scale
|
|
43
|
+
const p = bytes / MAX_SPEED;
|
|
44
|
+
return Math.min(1, p);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
|
|
26
48
|
export const start = async () => {
|
|
27
49
|
try {
|
|
28
50
|
await CoreManager.start();
|
|
51
|
+
await system.on();
|
|
29
52
|
} catch (error: any) {
|
|
30
53
|
console.error('Error starting core:', error.message);
|
|
31
54
|
}
|
|
@@ -33,6 +56,11 @@ export const start = async () => {
|
|
|
33
56
|
|
|
34
57
|
export const stop = async () => {
|
|
35
58
|
try {
|
|
59
|
+
const isProxyEnabled = await system.getSystemProxyStatus();
|
|
60
|
+
if (isProxyEnabled) {
|
|
61
|
+
console.log('System proxy is enabled. Disabling it...');
|
|
62
|
+
await system.off();
|
|
63
|
+
}
|
|
36
64
|
await CoreManager.stop();
|
|
37
65
|
} catch (error: any) {
|
|
38
66
|
console.error('Error stopping core:', error.message);
|
|
@@ -105,57 +133,65 @@ export const status = async () => {
|
|
|
105
133
|
|
|
106
134
|
if (!coreStatus.running) {
|
|
107
135
|
return (
|
|
108
|
-
<Box
|
|
109
|
-
<Text color="red"
|
|
136
|
+
<Box padding={1}>
|
|
137
|
+
<Text color="red">● Otter Core is stopped.</Text>
|
|
110
138
|
</Box>
|
|
111
139
|
);
|
|
112
140
|
}
|
|
113
141
|
|
|
114
142
|
return (
|
|
115
|
-
<Box flexDirection="column"
|
|
116
|
-
<Box
|
|
117
|
-
<Text color="
|
|
118
|
-
<Text color="gray">Press 'q' to exit</Text>
|
|
143
|
+
<Box flexDirection="column" padding={1}>
|
|
144
|
+
<Box marginBottom={1}>
|
|
145
|
+
<Text color="cyan" bold>● Otter Status</Text>
|
|
146
|
+
<Text color="gray"> (Press 'q' to exit)</Text>
|
|
119
147
|
</Box>
|
|
120
148
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
149
|
+
{/* System Info */}
|
|
150
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
151
|
+
<Text color="blue" bold>System</Text>
|
|
152
|
+
<Box marginLeft={2}>
|
|
153
|
+
<Box width={12}><Text>PID</Text></Box>
|
|
154
|
+
<Text color="gray">{coreStatus.pid}</Text>
|
|
125
155
|
</Box>
|
|
126
|
-
<Box>
|
|
127
|
-
<Box width={12}><Text>Version
|
|
128
|
-
<Text color="
|
|
156
|
+
<Box marginLeft={2}>
|
|
157
|
+
<Box width={12}><Text>Version</Text></Box>
|
|
158
|
+
<Text color="gray">{coreStatus.version}</Text>
|
|
129
159
|
</Box>
|
|
130
|
-
<Box>
|
|
131
|
-
<Box width={12}><Text>Memory
|
|
132
|
-
<Text color="
|
|
160
|
+
<Box marginLeft={2}>
|
|
161
|
+
<Box width={12}><Text>Memory</Text></Box>
|
|
162
|
+
<Text color="gray">{formatSize(coreStatus.memory || 0)}</Text>
|
|
133
163
|
</Box>
|
|
134
164
|
</Box>
|
|
135
165
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
166
|
+
{/* Configuration */}
|
|
167
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
168
|
+
<Text color="magenta" bold>Configuration</Text>
|
|
169
|
+
<Box marginLeft={2}>
|
|
170
|
+
<Box width={12}><Text>Profile</Text></Box>
|
|
171
|
+
<Text color="gray">{subInfo?.active || 'Default'}</Text>
|
|
140
172
|
</Box>
|
|
141
|
-
<Box>
|
|
142
|
-
<Box width={12}><Text>
|
|
143
|
-
<Text>{
|
|
144
|
-
</Box>
|
|
145
|
-
<Box>
|
|
146
|
-
<Box width={12}><Text>Proxies:</Text></Box>
|
|
147
|
-
<Text>{proxyCount}</Text>
|
|
173
|
+
<Box marginLeft={2}>
|
|
174
|
+
<Box width={12}><Text>Proxies</Text></Box>
|
|
175
|
+
<Text color="gray">{proxyCount} nodes</Text>
|
|
148
176
|
</Box>
|
|
149
177
|
</Box>
|
|
150
178
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
179
|
+
{/* Network Traffic */}
|
|
180
|
+
<Box flexDirection="column">
|
|
181
|
+
<Text color="green" bold>Network Traffic</Text>
|
|
182
|
+
|
|
183
|
+
<Box marginLeft={2} marginTop={1} flexDirection="column">
|
|
184
|
+
<Box>
|
|
185
|
+
<Box width={12}><Text>Upload</Text></Box>
|
|
186
|
+
<Box width={14}><Text color="yellow">{formatSpeed(traffic.up)}</Text></Box>
|
|
187
|
+
<ProgressBar percent={getSpeedPercent(traffic.up)} width={10} color="yellow" />
|
|
188
|
+
</Box>
|
|
189
|
+
|
|
190
|
+
<Box>
|
|
191
|
+
<Box width={12}><Text>Download</Text></Box>
|
|
192
|
+
<Box width={14}><Text color="green">{formatSpeed(traffic.down)}</Text></Box>
|
|
193
|
+
<ProgressBar percent={getSpeedPercent(traffic.down)} width={10} color="green" />
|
|
194
|
+
</Box>
|
|
159
195
|
</Box>
|
|
160
196
|
</Box>
|
|
161
197
|
</Box>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, Box, Text } from 'ink';
|
|
3
|
+
import SelectInput from 'ink-select-input';
|
|
4
|
+
|
|
5
|
+
const LOGO = `
|
|
6
|
+
____ __ __
|
|
7
|
+
/ __ \\/ /_/ /____ _____
|
|
8
|
+
/ / / / __/ __/ _ \\/ ___/
|
|
9
|
+
/ /_/ / /_/ /_/ __/ /
|
|
10
|
+
\\____/\\__/\\__/\\___/_/
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
interface MenuProps {
|
|
14
|
+
onSelect: (value: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const Menu: React.FC<MenuProps> = ({ onSelect }) => {
|
|
18
|
+
const items = [
|
|
19
|
+
{ label: 'Start'.padEnd(15) + 'Start Clash core & System Proxy', value: 'start' },
|
|
20
|
+
{ label: 'Stop'.padEnd(15) + 'Stop Clash core & Disable Proxy', value: 'stop' },
|
|
21
|
+
{ label: 'Status'.padEnd(15) + 'Check status & traffic', value: 'status' },
|
|
22
|
+
{ label: 'Dashboard'.padEnd(15) + 'Launch full TUI', value: 'ui' },
|
|
23
|
+
{ label: 'Quit'.padEnd(15) + 'Exit', value: 'quit' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Box flexDirection="column" padding={1}>
|
|
28
|
+
<Text color="green">{LOGO}</Text>
|
|
29
|
+
<Text color="gray" italic> v0.0.1</Text>
|
|
30
|
+
|
|
31
|
+
<Box marginTop={1} marginBottom={1}>
|
|
32
|
+
<Text>
|
|
33
|
+
<Text color="cyan">➤</Text> Use <Text bold>Arrow Keys</Text> to select and <Text bold>Enter</Text> to confirm
|
|
34
|
+
</Text>
|
|
35
|
+
</Box>
|
|
36
|
+
|
|
37
|
+
<SelectInput
|
|
38
|
+
items={items}
|
|
39
|
+
onSelect={(item) => onSelect(item.value)}
|
|
40
|
+
indicatorComponent={({ isSelected }) => (
|
|
41
|
+
<Text color={isSelected ? 'cyan' : 'gray'}>
|
|
42
|
+
{isSelected ? '➤ ' : ' '}
|
|
43
|
+
</Text>
|
|
44
|
+
)}
|
|
45
|
+
itemComponent={({ isSelected, label }) => (
|
|
46
|
+
<Text color={isSelected ? 'cyan' : 'white'}>
|
|
47
|
+
{label}
|
|
48
|
+
</Text>
|
|
49
|
+
)}
|
|
50
|
+
/>
|
|
51
|
+
|
|
52
|
+
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
|
53
|
+
<Text color="gray">
|
|
54
|
+
<Text>↑↓</Text> Navigate | <Text>Enter</Text> Select | <Text>Q</Text> Quit
|
|
55
|
+
</Text>
|
|
56
|
+
</Box>
|
|
57
|
+
</Box>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const show = () => {
|
|
62
|
+
return new Promise<string>((resolve) => {
|
|
63
|
+
const { unmount } = render(<Menu onSelect={(value) => {
|
|
64
|
+
unmount();
|
|
65
|
+
resolve(value);
|
|
66
|
+
}} />);
|
|
67
|
+
});
|
|
68
|
+
};
|