@meanc/otter 0.0.2 → 0.0.4

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/index.ts CHANGED
@@ -7,15 +7,25 @@ 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
9
  import * as menu from './src/commands/menu';
10
+ import * as test from './src/commands/test';
11
+ import * as connections from './src/commands/connections';
12
+ import * as rules from './src/commands/rules';
13
+ import * as smart from './src/commands/smart';
10
14
  import { BIN_PATH } from './src/utils/paths';
11
15
 
12
16
  const cli = cac('ot');
13
17
 
14
18
  // Core
15
- cli.command('up', 'Start Clash core').alias('start').action(core.start);
19
+ cli.command('up', 'Start Clash core').alias('start').option('--smart', 'Enable smart mode').action(core.start);
16
20
  cli.command('down', 'Stop Clash core').alias('stop').action(core.stop);
17
21
  cli.command('status', 'Check status').action(core.status);
18
22
  cli.command('log', 'Show logs').action(core.log);
23
+ cli.command('conns', 'Manage connections').alias('connections').action(connections.show);
24
+ cli.command('match <url>', 'Check which rule matches the URL').action(rules.match);
25
+ cli.command('smart', 'Start Smart Pilot mode').action(smart.start);
26
+
27
+ // Test
28
+ cli.command('test [group]', 'Speed test proxies').action(test.test);
19
29
 
20
30
  // Subscribe
21
31
  cli.command('sub <cmd> [arg1] [arg2]', 'Manage subscriptions')
@@ -60,7 +70,7 @@ cli.command('use [node]', 'Switch node')
60
70
  .option('-g, --global <index>', 'Select by global index')
61
71
  .option('-p, --proxy <index>', 'Select by proxy index')
62
72
  .action(proxy.use);
63
- cli.command('test', 'Test latency').action(proxy.test);
73
+ // cli.command('test', 'Test latency').action(proxy.test); // Replaced by src/commands/test.ts
64
74
  cli.command('best', 'Select best node').action(proxy.best);
65
75
 
66
76
  // System
@@ -96,11 +106,11 @@ cli.help((sections) => {
96
106
 
97
107
  cli.commands.forEach(cmd => {
98
108
  const name = cmd.name.split(' ')[0] || '';
99
- if (['up', 'down', 'status', 'log', 'start', 'stop'].includes(name)) {
109
+ if (['up', 'down', 'status', 'log', 'start', 'stop', 'conns'].includes(name)) {
100
110
  groups['Core Commands'].push(cmd);
101
111
  } else if (name === 'sub') {
102
112
  groups['Subscription Commands'].push(cmd);
103
- } else if (['ls', 'use', 'test', 'best'].includes(name)) {
113
+ } else if (['ls', 'use', 'test', 'best', 'match', 'smart'].includes(name)) {
104
114
  groups['Proxy Commands'].push(cmd);
105
115
  } else if (['on', 'off', 'shell', 'mode'].includes(name)) {
106
116
  groups['System Commands'].push(cmd);
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  },
6
6
  "description": "以水獭为名的clash tui",
7
7
  "module": "index.ts",
8
- "version": "0.0.2",
8
+ "version": "0.0.4",
9
9
  "bin": {
10
10
  "ot": "index.ts"
11
11
  },
@@ -0,0 +1,147 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { render, Box, Text, useInput, useApp } from 'ink';
3
+ import { ClashAPI } from '../utils/api';
4
+
5
+ const formatSize = (bytes: number) => {
6
+ if (bytes === 0) return '0 B';
7
+ const k = 1024;
8
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
9
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
10
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
11
+ };
12
+
13
+ const ConnectionsApp = () => {
14
+ const { exit } = useApp();
15
+ const [connections, setConnections] = useState<any[]>([]);
16
+ const [info, setInfo] = useState<any>({});
17
+ const [selectedIndex, setSelectedIndex] = useState(0);
18
+ const [message, setMessage] = useState('');
19
+
20
+ const refresh = async () => {
21
+ const data = await ClashAPI.getConnections();
22
+ if (data) {
23
+ // Sort by start time (newest first)
24
+ const sorted = (data.connections || []).sort((a: any, b: any) => {
25
+ return new Date(b.start).getTime() - new Date(a.start).getTime();
26
+ });
27
+ setConnections(sorted);
28
+ setInfo({
29
+ downloadTotal: data.downloadTotal,
30
+ uploadTotal: data.uploadTotal,
31
+ });
32
+ }
33
+ };
34
+
35
+ useEffect(() => {
36
+ refresh();
37
+ const interval = setInterval(refresh, 1000);
38
+ return () => clearInterval(interval);
39
+ }, []);
40
+
41
+ useInput(async (input, key) => {
42
+ if (input === 'q' || key.escape) {
43
+ exit();
44
+ return;
45
+ }
46
+
47
+ if (key.upArrow) {
48
+ setSelectedIndex(prev => Math.max(0, prev - 1));
49
+ }
50
+ if (key.downArrow) {
51
+ setSelectedIndex(prev => Math.min(connections.length - 1, prev + 1));
52
+ }
53
+
54
+ if (input === 'x') {
55
+ if (connections.length === 0) return;
56
+ const conn = connections[selectedIndex];
57
+ setMessage(`Closing connection to ${conn.metadata.host || conn.metadata.destinationIP}...`);
58
+ try {
59
+ await ClashAPI.closeConnection(conn.id);
60
+ setMessage('Connection closed.');
61
+ // Don't wait for refresh interval
62
+ setTimeout(refresh, 100);
63
+ } catch (e: any) {
64
+ setMessage(`Error: ${e.message}`);
65
+ }
66
+ }
67
+
68
+ if (input === 'X') {
69
+ setMessage('Closing ALL connections...');
70
+ try {
71
+ await ClashAPI.closeAllConnections();
72
+ setMessage('All connections closed.');
73
+ setTimeout(refresh, 100);
74
+ } catch (e: any) {
75
+ setMessage(`Error: ${e.message}`);
76
+ }
77
+ }
78
+ });
79
+
80
+ const visibleRows = 15;
81
+ let startRow = 0;
82
+ if (selectedIndex > visibleRows / 2) {
83
+ startRow = Math.min(selectedIndex - Math.floor(visibleRows / 2), connections.length - visibleRows);
84
+ }
85
+ startRow = Math.max(0, startRow);
86
+ const endRow = Math.min(startRow + visibleRows, connections.length);
87
+ const visibleConnections = connections.slice(startRow, endRow);
88
+
89
+ return (
90
+ <Box flexDirection="column" padding={1}>
91
+ <Box borderStyle="round" borderColor="blue" flexDirection="row" justifyContent="space-between" paddingX={1} marginBottom={1}>
92
+ <Text color="blue" bold>Active Connections: {connections.length}</Text>
93
+ <Box>
94
+ <Text>Total DL: <Text color="green">{formatSize(info.downloadTotal || 0)}</Text> | </Text>
95
+ <Text>Total UL: <Text color="yellow">{formatSize(info.uploadTotal || 0)}</Text></Text>
96
+ </Box>
97
+ </Box>
98
+
99
+ <Box flexDirection="column" borderStyle="single" borderColor="gray" paddingX={1} height={visibleRows + 2}>
100
+ <Box flexDirection="row" marginBottom={1}>
101
+ <Box width={30}><Text underline>Host/IP</Text></Box>
102
+ <Box width={20}><Text underline>Chains</Text></Box>
103
+ <Box width={15}><Text underline>Rule</Text></Box>
104
+ <Box width={15}><Text underline>Type</Text></Box>
105
+ </Box>
106
+
107
+ {visibleConnections.map((conn, idx) => {
108
+ const realIndex = startRow + idx;
109
+ const isSelected = realIndex === selectedIndex;
110
+ const host = conn.metadata.host || conn.metadata.destinationIP;
111
+ const chains = conn.chains.slice().reverse().join(' :: '); // Show last proxy first? No, usually first is entry.
112
+ // Actually chains usually: [ProxyGroup, Node].
113
+ const chainStr = conn.chains.length > 0 ? conn.chains[0] : 'DIRECT';
114
+
115
+ return (
116
+ <Box key={conn.id} flexDirection="row">
117
+ <Text color={isSelected ? 'cyan' : 'white'}>{isSelected ? '› ' : ' '}</Text>
118
+ <Box width={28}>
119
+ <Text color={isSelected ? 'cyan' : 'white'} wrap="truncate">
120
+ {host}:{conn.metadata.destinationPort}
121
+ </Text>
122
+ </Box>
123
+ <Box width={20}>
124
+ <Text color="gray" wrap="truncate">{chainStr}</Text>
125
+ </Box>
126
+ <Box width={15}>
127
+ <Text color="gray" wrap="truncate">{conn.rule}</Text>
128
+ </Box>
129
+ <Box width={15}>
130
+ <Text color="gray">{conn.metadata.network} ({conn.metadata.type})</Text>
131
+ </Box>
132
+ </Box>
133
+ );
134
+ })}
135
+ </Box>
136
+
137
+ <Box borderStyle="round" borderColor="gray" paddingX={1} marginTop={1}>
138
+ <Text>{message || 'Arrows: Navigate | [x]: Close Selected | [X]: Close ALL | q: Quit'}</Text>
139
+ </Box>
140
+ </Box>
141
+ );
142
+ };
143
+
144
+ export const show = async () => {
145
+ const { waitUntilExit } = render(<ConnectionsApp />);
146
+ await waitUntilExit();
147
+ };
@@ -1,12 +1,13 @@
1
1
  import React, { useState, useEffect } from 'react';
2
- import { render, Text, Box, useApp, useInput } from 'ink';
2
+ import { render, Text, Box, useApp, useInput, useStdout } from 'ink';
3
3
  import { CoreManager } from '../utils/core';
4
4
  import { ClashAPI } from '../utils/api';
5
5
  import { SubscriptionManager } from '../utils/subscription';
6
- import { LOG_FILE } from '../utils/paths';
6
+ import { LOG_FILE, SMART_LOG_FILE, SMART_PID_FILE } from '../utils/paths';
7
7
  import { spawn } from 'child_process';
8
8
  import fs from 'fs-extra';
9
9
  import * as system from './system';
10
+ import { TrafficGraph } from '../components/TrafficGraph';
10
11
 
11
12
  const formatSpeed = (bytes: number) => {
12
13
  if (bytes === 0) return '0 B/s';
@@ -44,10 +45,59 @@ const getSpeedPercent = (bytes: number) => {
44
45
  return Math.min(1, p);
45
46
  };
46
47
 
48
+ const startSmartPilot = async () => {
49
+ // Check if already running
50
+ if (await fs.pathExists(SMART_PID_FILE)) {
51
+ try {
52
+ const pid = parseInt(await fs.readFile(SMART_PID_FILE, 'utf-8'), 10);
53
+ // Check if process exists
54
+ process.kill(pid, 0);
55
+ console.log('Smart Pilot is already running.');
56
+ return;
57
+ } catch (e) {
58
+ // Process doesn't exist, remove stale PID file
59
+ await fs.remove(SMART_PID_FILE);
60
+ }
61
+ }
62
+
63
+ await fs.ensureFile(SMART_LOG_FILE);
64
+ const logFd = await fs.open(SMART_LOG_FILE, 'a');
65
+
66
+ const child = spawn(process.argv[0] || 'bun', [process.argv[1] || '', 'smart'], {
67
+ detached: true,
68
+ stdio: ['ignore', logFd, logFd]
69
+ }) as any;
70
+
71
+ if (child.pid) {
72
+ await fs.writeFile(SMART_PID_FILE, child.pid.toString());
73
+ child.unref();
74
+ console.log(`Smart Pilot started with PID ${child.pid}`);
75
+ } else {
76
+ console.error('Failed to start Smart Pilot');
77
+ }
78
+ };
79
+
80
+ const stopSmartPilot = async () => {
81
+ if (await fs.pathExists(SMART_PID_FILE)) {
82
+ const pid = parseInt(await fs.readFile(SMART_PID_FILE, 'utf-8'), 10);
83
+ if (!isNaN(pid)) {
84
+ try {
85
+ process.kill(pid);
86
+ console.log('Smart Pilot stopped.');
87
+ } catch (e) {
88
+ // Process might be already gone
89
+ }
90
+ }
91
+ await fs.remove(SMART_PID_FILE);
92
+ }
93
+ };
47
94
 
48
- export const start = async () => {
95
+ export const start = async (options: { smart?: boolean } = {}) => {
49
96
  try {
50
97
  await CoreManager.start();
98
+ if (options.smart) {
99
+ await startSmartPilot();
100
+ }
51
101
  await system.on();
52
102
  } catch (error: any) {
53
103
  console.error('Error starting core:', error.message);
@@ -61,6 +111,7 @@ export const stop = async () => {
61
111
  console.log('System proxy is enabled. Disabling it...');
62
112
  await system.off();
63
113
  }
114
+ await stopSmartPilot();
64
115
  await CoreManager.stop();
65
116
  } catch (error: any) {
66
117
  console.error('Error stopping core:', error.message);
@@ -70,8 +121,11 @@ export const stop = async () => {
70
121
  export const status = async () => {
71
122
  const StatusApp = () => {
72
123
  const { exit } = useApp();
124
+ const { stdout } = useStdout();
125
+ const [width, setWidth] = useState(stdout.columns);
73
126
  const [coreStatus, setCoreStatus] = useState<any>(null);
74
127
  const [traffic, setTraffic] = useState({ up: 0, down: 0 });
128
+ const [history, setHistory] = useState<{ up: number[], down: number[] }>({ up: [], down: [] });
75
129
  const [subInfo, setSubInfo] = useState<{ active: string | null, count: number } | null>(null);
76
130
  const [proxyCount, setProxyCount] = useState<number>(0);
77
131
 
@@ -81,6 +135,18 @@ export const status = async () => {
81
135
  }
82
136
  });
83
137
 
138
+ useEffect(() => {
139
+ const onResize = () => setWidth(stdout.columns);
140
+ stdout.on('resize', onResize);
141
+ return () => {
142
+ stdout.off('resize', onResize);
143
+ };
144
+ }, [stdout]);
145
+
146
+ // Calculate dynamic graph width (terminal width - padding)
147
+ // Padding: marginLeft(2) + padding(1) + border(2) approx 6-8 chars
148
+ const graphWidth = Math.max(20, Math.min(width - 8, 120));
149
+
84
150
  useEffect(() => {
85
151
  let ws: WebSocket | null = null;
86
152
 
@@ -110,6 +176,12 @@ export const status = async () => {
110
176
  ws.onmessage = (event) => {
111
177
  const data = JSON.parse(event.data as string);
112
178
  setTraffic(data);
179
+ setHistory(prev => {
180
+ // Keep enough history for wide screens
181
+ const newUp = [...prev.up, data.up].slice(-120);
182
+ const newDown = [...prev.down, data.down].slice(-120);
183
+ return { up: newUp, down: newDown };
184
+ });
113
185
  };
114
186
  } catch (e) { }
115
187
  }
@@ -181,16 +253,22 @@ export const status = async () => {
181
253
  <Text color="green" bold>Network Traffic</Text>
182
254
 
183
255
  <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" />
256
+ {/* Upload Section */}
257
+ <Box flexDirection="column" marginBottom={1}>
258
+ <Box flexDirection="row" width={graphWidth} justifyContent="space-between" marginBottom={0}>
259
+ <Text>Upload</Text>
260
+ <Text color="yellow">{formatSpeed(traffic.up)}</Text>
261
+ </Box>
262
+ <TrafficGraph data={history.up} width={graphWidth} height={4} color="yellow" />
188
263
  </Box>
189
264
 
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" />
265
+ {/* Download Section */}
266
+ <Box flexDirection="column">
267
+ <Box flexDirection="row" width={graphWidth} justifyContent="space-between" marginBottom={0}>
268
+ <Text>Download</Text>
269
+ <Text color="green">{formatSpeed(traffic.down)}</Text>
270
+ </Box>
271
+ <TrafficGraph data={history.down} width={graphWidth} height={4} color="green" />
194
272
  </Box>
195
273
  </Box>
196
274
  </Box>
@@ -26,7 +26,7 @@ const Menu: React.FC<MenuProps> = ({ onSelect }) => {
26
26
  return (
27
27
  <Box flexDirection="column" padding={1}>
28
28
  <Text color="green">{LOGO}</Text>
29
- <Text color="gray" italic> v0.0.1</Text>
29
+ <Text color="gray" italic> v0.0.4</Text>
30
30
 
31
31
  <Box marginTop={1} marginBottom={1}>
32
32
  <Text>
@@ -147,7 +147,7 @@ export const test = async () => {
147
147
  };
148
148
 
149
149
  export const best = async () => {
150
- console.log(chalk.blue('Testing all nodes to find the best one...'));
150
+ console.log(chalk.blue('Testing Proxy group to find the best one...'));
151
151
 
152
152
  const data = await ClashAPI.getProxies();
153
153
  if (!data || !data.proxies) {
@@ -156,17 +156,11 @@ export const best = async () => {
156
156
  }
157
157
 
158
158
  const proxies = data.proxies;
159
- const groups = Object.values(proxies).filter((p: any) => p.type === 'Selector');
160
-
161
- if (groups.length === 0) {
162
- console.error(chalk.red('No selector groups found in config.'));
163
- return;
164
- }
165
-
166
- const mainGroup: any = groups.find((g: any) => ['Proxy', 'GLOBAL'].includes(g.name)) || groups[0];
159
+ // Explicitly look for 'Proxy' group
160
+ const mainGroup: any = proxies['Proxy'];
167
161
 
168
162
  if (!mainGroup) {
169
- console.error(chalk.red('Could not determine main proxy group.'));
163
+ console.error(chalk.red("Group 'Proxy' not found."));
170
164
  return;
171
165
  }
172
166
 
@@ -0,0 +1,132 @@
1
+ import { ClashAPI } from '../utils/api';
2
+ import chalk from 'chalk';
3
+
4
+ interface Rule {
5
+ type: string;
6
+ payload: string;
7
+ proxy: string;
8
+ size?: number;
9
+ }
10
+
11
+ export const match = async (urlOrHost: string) => {
12
+ if (!urlOrHost) {
13
+ console.error(chalk.red('Please provide a URL or hostname.'));
14
+ return;
15
+ }
16
+
17
+ // Clean input to get hostname
18
+ let hostname = urlOrHost;
19
+ try {
20
+ if (!hostname.startsWith('http')) {
21
+ hostname = 'http://' + hostname;
22
+ }
23
+ hostname = new URL(hostname).hostname;
24
+ } catch (e) {
25
+ console.error(chalk.red('Invalid URL or hostname.'));
26
+ return;
27
+ }
28
+
29
+ console.log(chalk.blue(`Analyzing rules for: ${chalk.bold(hostname)}`));
30
+
31
+ const config = await ClashAPI.getConfigs();
32
+ if (config && config.mode === 'Global') {
33
+ console.log(chalk.yellow('Warning: System is in Global mode. All traffic goes to GLOBAL/Proxy.'));
34
+ }
35
+
36
+ const baseUrl = await (ClashAPI as any).getBaseUrl();
37
+ const headers = await (ClashAPI as any).getHeaders();
38
+
39
+ let rules: Rule[] = [];
40
+ try {
41
+ const res = await fetch(`${baseUrl}/rules`, { headers });
42
+ if (!res.ok) throw new Error(res.statusText);
43
+ const data = await res.json() as { rules: Rule[] };
44
+ rules = data?.rules || [];
45
+ } catch (e: any) {
46
+ console.error(chalk.red(`Failed to fetch rules: ${e.message}`));
47
+ return;
48
+ }
49
+
50
+ console.log(chalk.gray(`Loaded ${rules.length} rules.`));
51
+
52
+ let matchedRule: Rule | null = null;
53
+
54
+ for (const rule of rules) {
55
+ const type = rule.type.toLowerCase();
56
+ const payload = rule.payload;
57
+ const lowerHost = hostname.toLowerCase();
58
+ const lowerPayload = payload.toLowerCase();
59
+
60
+ let isMatch = false;
61
+
62
+ switch (type) {
63
+ case 'domain':
64
+ isMatch = lowerHost === lowerPayload;
65
+ break;
66
+ case 'domainsuffix':
67
+ case 'domain-suffix':
68
+ isMatch = lowerHost === lowerPayload || lowerHost.endsWith('.' + lowerPayload);
69
+ break;
70
+ case 'domainkeyword':
71
+ case 'domain-keyword':
72
+ isMatch = lowerHost.includes(lowerPayload);
73
+ break;
74
+ case 'match':
75
+ isMatch = true;
76
+ break;
77
+ case 'geoip':
78
+ case 'ip-cidr':
79
+ case 'ip-cidr6':
80
+ // Cannot match IP rules without DNS resolution
81
+ // We skip them but maybe log a warning if verbose
82
+ break;
83
+ default:
84
+ break;
85
+ }
86
+
87
+ if (isMatch) {
88
+ matchedRule = rule;
89
+ break; // Clash uses first match
90
+ }
91
+ }
92
+
93
+ if (matchedRule) {
94
+ console.log('');
95
+ console.log(chalk.green('✔ Match Found!'));
96
+ console.log('─'.repeat(30));
97
+ console.log(`Type: ${chalk.cyan(matchedRule.type)}`);
98
+ console.log(`Payload: ${chalk.yellow(matchedRule.payload || '(Final)')}`);
99
+ console.log(`Proxy: ${chalk.magenta(matchedRule.proxy)}`);
100
+
101
+ // Resolve Proxy Details
102
+ try {
103
+ const proxyData = await ClashAPI.getProxies();
104
+ if (proxyData && proxyData.proxies) {
105
+ const proxyName = matchedRule.proxy;
106
+ const proxyInfo = proxyData.proxies[proxyName];
107
+
108
+ if (proxyInfo) {
109
+ console.log(` └─ Type: ${proxyInfo.type}`);
110
+ if (proxyInfo.now) {
111
+ console.log(` └─ Now: ${chalk.green(proxyInfo.now)}`);
112
+ }
113
+ // If the 'now' node is also a group, we could recurse, but one level is usually enough context
114
+ if (proxyInfo.now && proxyData.proxies[proxyInfo.now]) {
115
+ const nextHop = proxyData.proxies[proxyInfo.now];
116
+ if (nextHop.type === 'Selector' || nextHop.type === 'URLTest') {
117
+ console.log(` └─ Next: ${chalk.green(nextHop.now)}`);
118
+ }
119
+ }
120
+ }
121
+ }
122
+ } catch (e) {
123
+ // Ignore proxy resolution errors
124
+ }
125
+
126
+ console.log('─'.repeat(30));
127
+ } else {
128
+ console.log('');
129
+ console.log(chalk.red('✘ No matching rule found (and no Match rule present?).'));
130
+ console.log('Traffic might fall through to default behavior.');
131
+ }
132
+ };
@@ -0,0 +1,151 @@
1
+ import { ClashAPI } from '../utils/api';
2
+ import { SubscriptionManager } from '../utils/subscription';
3
+ import * as system from './system';
4
+ import chalk from 'chalk';
5
+
6
+ const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
7
+
8
+ const findBestNode = async (nodes: string[]): Promise<{ name: string, delay: number } | null> => {
9
+ if (nodes.length === 0) return null;
10
+
11
+ // Test up to 5 nodes concurrently to save time
12
+ const candidates = nodes.slice(0, 5);
13
+
14
+ const results = await Promise.all(candidates.map(async (node) => {
15
+ try {
16
+ const res = await ClashAPI.getDelay(node) as { delay: number };
17
+ return { name: node, delay: res.delay };
18
+ } catch {
19
+ return { name: node, delay: Infinity };
20
+ }
21
+ }));
22
+
23
+ // Filter valid results and sort
24
+ const valid = results.filter(r => r.delay < 2000).sort((a, b) => a.delay - b.delay);
25
+
26
+ if (valid.length > 0) {
27
+ const best = valid[0];
28
+ if (best) {
29
+ return { name: best.name, delay: best.delay };
30
+ }
31
+ }
32
+ return null;
33
+ };
34
+
35
+ export const start = async () => {
36
+ console.log(chalk.cyan(chalk.bold('🦦 Otter Smart Pilot Initiated')));
37
+ console.log(chalk.gray('Monitoring network health and managing failover...'));
38
+ console.log(chalk.gray('Press Ctrl+C to stop.'));
39
+ console.log('─'.repeat(40));
40
+
41
+ while (true) {
42
+ try {
43
+ // 1. Health Check
44
+ const data = await ClashAPI.getProxies();
45
+ if (!data || !data.proxies) {
46
+ console.log(chalk.red('Otter Core not reachable. Retrying in 5s...'));
47
+ await sleep(5000);
48
+ continue;
49
+ }
50
+
51
+ const proxyGroup = data.proxies['Proxy'];
52
+ if (!proxyGroup) {
53
+ console.log(chalk.red("Group 'Proxy' not found."));
54
+ await sleep(10000);
55
+ continue;
56
+ }
57
+
58
+ const currentNode = proxyGroup.now;
59
+ process.stdout.write(`[${new Date().toLocaleTimeString()}] Checking ${currentNode}... `);
60
+
61
+ let isHealthy = false;
62
+ try {
63
+ const res = await ClashAPI.getDelay(currentNode) as { delay: number };
64
+ if (res.delay < 1500) {
65
+ console.log(chalk.green(`OK (${res.delay}ms)`));
66
+ isHealthy = true;
67
+ } else {
68
+ console.log(chalk.yellow(`High Latency (${res.delay}ms)`));
69
+ }
70
+ } catch {
71
+ console.log(chalk.red('Timeout'));
72
+ }
73
+
74
+ if (isHealthy) {
75
+ await sleep(10000); // Check every 10s if healthy
76
+ continue;
77
+ }
78
+
79
+ // 2. Fallback Logic
80
+ console.log(chalk.yellow('⚠ Connection unstable. Initiating fallback protocol...'));
81
+
82
+ const allNodes: string[] = proxyGroup.all;
83
+
84
+ // Regex patterns
85
+ const regions = [
86
+ { name: 'Hong Kong', regex: /HK|Hong Kong|香港/i },
87
+ { name: 'Japan', regex: /JP|Japan|日本/i },
88
+ { name: 'USA', regex: /US|USA|United States|美国/i },
89
+ ];
90
+
91
+ let fallbackSuccess = false;
92
+
93
+ for (const region of regions) {
94
+ console.log(chalk.blue(`Searching for healthy ${region.name} nodes...`));
95
+ const regionNodes = allNodes.filter(n => region.regex.test(n));
96
+
97
+ const best = await findBestNode(regionNodes);
98
+ if (best) {
99
+ console.log(chalk.green(`✔ Found ${region.name} node: ${best.name} (${best.delay}ms)`));
100
+ await ClashAPI.switchProxy('Proxy', best.name);
101
+ console.log(chalk.green(`Switched to ${best.name}`));
102
+ fallbackSuccess = true;
103
+ break;
104
+ } else {
105
+ console.log(chalk.gray(`✘ No healthy ${region.name} nodes found.`));
106
+ }
107
+ }
108
+
109
+ if (fallbackSuccess) {
110
+ await sleep(5000); // Wait a bit before next check
111
+ continue;
112
+ }
113
+
114
+ // 3. Disaster Recovery
115
+ console.log(chalk.red.bold('‼ All fallback regions failed. Initiating EMERGENCY UPDATE...'));
116
+
117
+ console.log(chalk.yellow('1. Disabling System Proxy...'));
118
+ await system.off(true); // silent
119
+
120
+ console.log(chalk.yellow('2. Updating Subscriptions...'));
121
+ const subData = await SubscriptionManager.getData();
122
+ for (const sub of subData.subscriptions) {
123
+ process.stdout.write(`Updating ${sub.name}... `);
124
+ try {
125
+ await SubscriptionManager.update(sub.name);
126
+ console.log(chalk.green('Done'));
127
+ } catch (e: any) {
128
+ console.log(chalk.red('Failed'));
129
+ }
130
+ }
131
+
132
+ console.log(chalk.yellow('3. Re-enabling System Proxy...'));
133
+ await system.on(true);
134
+
135
+ console.log(chalk.yellow('4. Selecting Best Node...'));
136
+ // Simple best node selection from all nodes
137
+ const bestAny = await findBestNode(allNodes);
138
+ if (bestAny) {
139
+ await ClashAPI.switchProxy('Proxy', bestAny.name);
140
+ console.log(chalk.green(`Recovered! Switched to ${bestAny.name}`));
141
+ } else {
142
+ console.log(chalk.red('Recovery failed. No reachable nodes found. Retrying in 1 minute...'));
143
+ await sleep(60000);
144
+ }
145
+
146
+ } catch (e: any) {
147
+ console.error(chalk.red(`Smart Pilot Error: ${e.message}`));
148
+ await sleep(5000);
149
+ }
150
+ }
151
+ };
@@ -134,7 +134,7 @@ export const mode = async (modeName?: string) => {
134
134
  return;
135
135
  }
136
136
 
137
- const validModes = ['global', 'rule', 'direct'];
137
+ const validModes = ['global', 'rule', 'direct', 'script'];
138
138
  const m = modeName.toLowerCase();
139
139
  if (!validModes.includes(m)) {
140
140
  console.error(chalk.red(`Invalid mode: ${modeName}. Valid modes: ${validModes.join(', ')}`));
@@ -0,0 +1,70 @@
1
+ import { ClashAPI } from '../utils/api';
2
+ import chalk from 'chalk';
3
+
4
+ export const test = async (target: string = 'Proxy') => {
5
+ console.log(chalk.blue(`Testing latency for: ${target}...`));
6
+
7
+ const data = await ClashAPI.getProxies();
8
+ if (!data || !data.proxies) {
9
+ console.error(chalk.red('Failed to fetch proxies. Is Otter running?'));
10
+ return;
11
+ }
12
+
13
+ const item = data.proxies[target];
14
+ if (!item) {
15
+ console.error(chalk.red(`Proxy or Group '${target}' not found.`));
16
+ const availableGroups = Object.values(data.proxies)
17
+ .filter((p: any) => p.type === 'Selector')
18
+ .map((p: any) => p.name)
19
+ .join(', ');
20
+ console.log(chalk.gray(`Available groups: ${availableGroups}`));
21
+ return;
22
+ }
23
+
24
+ let nodes: string[] = [];
25
+ if (item.all && Array.isArray(item.all)) {
26
+ nodes = item.all;
27
+ } else {
28
+ // It's a single node
29
+ nodes = [target];
30
+ }
31
+
32
+ console.log(chalk.bold(`Starting speed test for ${nodes.length} nodes...`));
33
+ console.log('─'.repeat(40));
34
+
35
+ const printResult = (name: string, delay: number | string) => {
36
+ let delayDisplay = '';
37
+ let nameDisplay = name;
38
+
39
+ if (typeof delay === 'number') {
40
+ const ms = delay;
41
+ let color = chalk.green;
42
+ if (ms > 400) color = chalk.yellow;
43
+ if (ms > 1000) color = chalk.red;
44
+
45
+ delayDisplay = color(`${ms}ms`.padEnd(8));
46
+ } else {
47
+ delayDisplay = chalk.gray('Timeout '.padEnd(8));
48
+ nameDisplay = chalk.gray(name);
49
+ }
50
+
51
+ console.log(`${delayDisplay} ${nameDisplay}`);
52
+ };
53
+
54
+ // Process in batches
55
+ const batchSize = 10;
56
+ for (let i = 0; i < nodes.length; i += batchSize) {
57
+ const batch = nodes.slice(i, i + batchSize);
58
+ await Promise.all(batch.map(async (node) => {
59
+ try {
60
+ const res = await ClashAPI.getDelay(node) as { delay: number };
61
+ printResult(node, res.delay);
62
+ } catch (e) {
63
+ printResult(node, 'Timeout');
64
+ }
65
+ }));
66
+ }
67
+
68
+ console.log('─'.repeat(40));
69
+ console.log(chalk.green('Test completed.'));
70
+ };
@@ -178,6 +178,49 @@ const TuiApp = () => {
178
178
  return;
179
179
  }
180
180
 
181
+ if (input === 'b') {
182
+ if (groups.length === 0) return;
183
+ const currentGroup = groups[selectedGroupIndex];
184
+ setMessage(`Testing ${currentGroup.name} for best node...`);
185
+
186
+ // Run test in background
187
+ (async () => {
188
+ try {
189
+ const candidates: string[] = currentGroup.all;
190
+ let bestNode = '';
191
+ let minDelay = Infinity;
192
+
193
+ // Test all nodes
194
+ const results = await Promise.all(candidates.map(async (node) => {
195
+ try {
196
+ const res = await ClashAPI.getDelay(node);
197
+ return { node, delay: res.delay };
198
+ } catch {
199
+ return { node, delay: Infinity };
200
+ }
201
+ }));
202
+
203
+ results.forEach(r => {
204
+ if (r.delay < minDelay && r.delay > 0) {
205
+ minDelay = r.delay;
206
+ bestNode = r.node;
207
+ }
208
+ });
209
+
210
+ if (bestNode) {
211
+ await ClashAPI.switchProxy(currentGroup.name, bestNode);
212
+ setMessage(`Auto-selected: ${bestNode} (${minDelay}ms)`);
213
+ await refreshProxies();
214
+ } else {
215
+ setMessage('No reachable nodes found.');
216
+ }
217
+ } catch (e: any) {
218
+ setMessage(`Error: ${e.message}`);
219
+ }
220
+ })();
221
+ return;
222
+ }
223
+
181
224
  if (groups.length === 0) return;
182
225
 
183
226
  const currentGroup = groups[selectedGroupIndex];
@@ -310,7 +353,7 @@ const TuiApp = () => {
310
353
 
311
354
  {/* Footer / Status Bar */}
312
355
  <Box borderStyle="round" borderColor="gray" paddingX={1}>
313
- <Text>{message || 'Arrows: Navigate | Enter: Select | Tab: Switch Panel | [s]: SysProxy | [m]: Mode | q: Quit'}</Text>
356
+ <Text>{message || 'Arrows: Navigate | Enter: Select | Tab: Switch Panel | [s]: SysProxy | [m]: Mode | [b]: Best | q: Quit'}</Text>
314
357
  </Box>
315
358
  </Box>
316
359
  );
@@ -0,0 +1,70 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ interface TrafficGraphProps {
5
+ data: number[];
6
+ width?: number;
7
+ height?: number;
8
+ color?: string;
9
+ max?: number;
10
+ }
11
+
12
+ const formatSize = (bytes: number) => {
13
+ if (bytes === 0) return '0 B';
14
+ const k = 1024;
15
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
16
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
17
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + (sizes[i] || 'B');
18
+ };
19
+
20
+ export const TrafficGraph: React.FC<TrafficGraphProps> = ({
21
+ data,
22
+ width = 40,
23
+ height = 4,
24
+ color = 'green',
25
+ max
26
+ }) => {
27
+ // Pad with 0s at the start if data is less than width
28
+ const filledData = [...(new Array(Math.max(0, width - data.length)).fill(0)), ...data].slice(-width);
29
+
30
+ // Calculate max value for scaling
31
+ // Use a minimum max value (e.g. 1KB) to avoid flat lines on 0
32
+ const maxValue = max || Math.max(...filledData, 1024);
33
+
34
+ const rows = [];
35
+ const chars = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
36
+
37
+ for (let y = height - 1; y >= 0; y--) {
38
+ let rowStr = '';
39
+ for (let x = 0; x < width; x++) {
40
+ const value = filledData[x];
41
+ const ratio = value / maxValue;
42
+ const scaledValue = ratio * height;
43
+
44
+ if (scaledValue >= y + 1) {
45
+ rowStr += '█';
46
+ } else if (scaledValue > y) {
47
+ const remainder = scaledValue - y;
48
+ const charIndex = Math.floor(remainder * (chars.length - 1));
49
+ rowStr += chars[charIndex];
50
+ } else {
51
+ rowStr += ' ';
52
+ }
53
+ }
54
+ rows.push(rowStr);
55
+ }
56
+
57
+ return (
58
+ <Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
59
+ <Box flexDirection="column">
60
+ {rows.map((row, i) => (
61
+ <Text key={i} color={color}>{row}</Text>
62
+ ))}
63
+ </Box>
64
+ <Box justifyContent="space-between" marginTop={0}>
65
+ <Text color="gray" dimColor>0</Text>
66
+ <Text color="gray" dimColor>{formatSize(maxValue)}</Text>
67
+ </Box>
68
+ </Box>
69
+ );
70
+ };
package/src/utils/api.ts CHANGED
@@ -100,6 +100,38 @@ export class ClashAPI {
100
100
  if (!res.ok) throw new Error(`Failed to update config: ${res.statusText}`);
101
101
  }
102
102
 
103
+ static async getConnections(): Promise<any> {
104
+ const baseUrl = await this.getBaseUrl();
105
+ const headers = await this.getHeaders();
106
+ try {
107
+ const res = await fetch(`${baseUrl}/connections`, { headers });
108
+ if (!res.ok) throw new Error(`API Error: ${res.statusText}`);
109
+ return await res.json();
110
+ } catch (e) {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ static async closeConnection(id: string) {
116
+ const baseUrl = await this.getBaseUrl();
117
+ const headers = await this.getHeaders();
118
+ const res = await fetch(`${baseUrl}/connections/${id}`, {
119
+ method: 'DELETE',
120
+ headers
121
+ });
122
+ if (!res.ok) throw new Error(`Failed to close connection: ${res.statusText}`);
123
+ }
124
+
125
+ static async closeAllConnections() {
126
+ const baseUrl = await this.getBaseUrl();
127
+ const headers = await this.getHeaders();
128
+ const res = await fetch(`${baseUrl}/connections`, {
129
+ method: 'DELETE',
130
+ headers
131
+ });
132
+ if (!res.ok) throw new Error(`Failed to close all connections: ${res.statusText}`);
133
+ }
134
+
103
135
  static async getTrafficUrl(): Promise<string> {
104
136
  const baseUrl = await this.getBaseUrl();
105
137
  return baseUrl.replace('http://', 'ws://') + '/traffic';
@@ -4,7 +4,9 @@ import os from 'os';
4
4
  export const HOME_DIR = path.join(os.homedir(), '.otter');
5
5
  export const CONFIG_FILE = path.join(HOME_DIR, 'config.yaml');
6
6
  export const PID_FILE = path.join(HOME_DIR, 'otter.pid');
7
+ export const SMART_PID_FILE = path.join(HOME_DIR, 'smart.pid');
7
8
  export const LOG_FILE = path.join(HOME_DIR, 'otter.log');
9
+ export const SMART_LOG_FILE = path.join(HOME_DIR, 'smart.log');
8
10
  export const BIN_PATH = path.resolve(import.meta.dir, '../../bin/mihomo');
9
11
  export const SUBSCRIPTIONS_FILE = path.join(HOME_DIR, 'subscriptions.json');
10
12
  export const PROFILES_DIR = path.join(HOME_DIR, 'profiles');