@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 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
- cli.parse();
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.1",
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",
@@ -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 borderStyle="round" borderColor="red" padding={1}>
109
- <Text color="red">Otter Core is stopped.</Text>
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" borderStyle="round" borderColor="green" padding={1} width={50}>
116
- <Box justifyContent="space-between">
117
- <Text color="green" bold>Otter Core is running</Text>
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
- <Box marginTop={1} flexDirection="column">
122
- <Box>
123
- <Box width={12}><Text>PID:</Text></Box>
124
- <Text color="blue">{coreStatus.pid}</Text>
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:</Text></Box>
128
- <Text color="yellow">{coreStatus.version}</Text>
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:</Text></Box>
132
- <Text color="cyan">{formatSize(coreStatus.memory || 0)}</Text>
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
- <Box marginTop={1} borderStyle="single" borderColor="gray" flexDirection="column">
137
- <Box>
138
- <Box width={12}><Text>Active Sub:</Text></Box>
139
- <Text color="magenta">{subInfo?.active || 'None'}</Text>
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>Total Subs:</Text></Box>
143
- <Text>{subInfo?.count || 0}</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
- <Box marginTop={1} flexDirection="row" justifyContent="space-around">
152
- <Box flexDirection="column" alignItems="center">
153
- <Text>Upload</Text>
154
- <Text color="green">↑ {formatSpeed(traffic.up)}</Text>
155
- </Box>
156
- <Box flexDirection="column" alignItems="center">
157
- <Text>Download</Text>
158
- <Text color="green">↓ {formatSpeed(traffic.down)}</Text>
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
+ };